@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +2 -0
  3. package/capabilities/host-integration/external-mcp/GUIDE.md +29 -0
  4. package/capabilities/lifecycle-hooks/GUIDE.md +13 -3
  5. package/dist/mcp/filesystem/filesystem-tools.js +7 -3
  6. package/dist/mcp/hooks/command-runner.js +12 -7
  7. package/dist/mcp/hooks/external-mcp-transform.js +224 -0
  8. package/dist/mcp/hooks/hook-cli.js +3 -0
  9. package/dist/mcp/hooks/hooks.js +49 -3
  10. package/dist/mcp/operations/bulk-read.js +7 -6
  11. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +42 -0
  12. package/dist/mcp/operations/external-mcp/external-mcp.js +227 -0
  13. package/dist/mcp/operations/media-content.js +28 -0
  14. package/dist/mcp/server/core/activity-support.js +2 -2
  15. package/dist/mcp/server/core/capabilities/external-mcp.js +31 -0
  16. package/dist/mcp/server/core/capabilities.js +10 -0
  17. package/dist/mcp/server/core/capability-registry.js +2 -0
  18. package/dist/mcp/server/core/tool-support.js +29 -0
  19. package/dist/mcp/server/operations/runtime/filesystem-tools.js +57 -21
  20. package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -4
  21. package/dist/mcp/server/transport/http-server.js +1 -1
  22. package/dist/runtime/config/config.js +4 -0
  23. package/dist/runtime/config/external-mcp-config.js +92 -0
  24. package/dist/runtime/testing/server-fixture.js +6 -1
  25. package/dist/server.js +4 -1
  26. package/dist/subagents/sessions/mcp/audit.js +81 -0
  27. package/dist/workspaces/relay/result-support.js +19 -0
  28. package/dist/workspaces/relay/tests/test-support.js +3 -0
  29. package/dist/workspaces/relay/workspace-relay.js +6 -4
  30. package/docs/configuration.md +50 -2
  31. package/package.json +2 -1
  32. package/scripts/debug/accept/bootstrap.mjs +3 -0
  33. package/scripts/debug/accept/harness.mjs +1 -1
  34. package/scripts/debug/accept/media.mjs +335 -0
  35. package/scripts/debug/accept.mjs +11 -1
  36. package/scripts/debug/relay-accept/support.mjs +40 -0
  37. package/scripts/debug/relay-accept.mjs +8 -9
@@ -0,0 +1,227 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { claimMediaBytes, createMediaBudget, isSupportedImageMimeType, strictBase64ByteLength, } from "../media-content.js";
6
+ import { ExternalMcpTransformError, } from "../../hooks/external-mcp-transform.js";
7
+ const MAX_DISCOVERED_TOOLS = 100;
8
+ const MAX_TOOL_DESCRIPTION_CHARS = 2_000;
9
+ const MAX_TOOL_SCHEMA_BYTES = 64 * 1024;
10
+ const MAX_TOOL_DISCOVERY_PAGES = 16;
11
+ export class ExternalMcpError extends Error {
12
+ code;
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = "ExternalMcpError";
17
+ }
18
+ }
19
+ export class ExternalMcpGateway {
20
+ servers;
21
+ mediaMaxBytes;
22
+ constructor(servers, mediaMaxBytes) {
23
+ this.servers = servers;
24
+ this.mediaMaxBytes = mediaMaxBytes;
25
+ }
26
+ get available() {
27
+ return Object.keys(this.servers).length > 0;
28
+ }
29
+ async run(input, signal, transforms) {
30
+ signal?.throwIfAborted();
31
+ switch (input.operation) {
32
+ case "servers":
33
+ return {
34
+ value: {
35
+ operation: "servers",
36
+ servers: Object.entries(this.servers)
37
+ .sort(([left], [right]) => left.localeCompare(right))
38
+ .map(([name, server]) => ({ name, transport: server.transport })),
39
+ },
40
+ };
41
+ case "tools":
42
+ return this.withClient(input.server, signal, async (client) => {
43
+ const discovery = await discoverTools(client, signal);
44
+ return {
45
+ value: {
46
+ operation: "tools",
47
+ server: input.server,
48
+ tools: discovery.tools.map(summarizeTool),
49
+ ...(discovery.truncated ? { truncated: true } : {}),
50
+ },
51
+ };
52
+ });
53
+ case "call":
54
+ return this.withClient(input.server, signal, async (client) => {
55
+ await assertRegisteredTool(client, input.server, input.tool, signal);
56
+ const appliedTransforms = [];
57
+ let callArguments = input.arguments ?? {};
58
+ if (transforms?.request) {
59
+ const transformed = await transforms.request(input.server, input.tool, callArguments);
60
+ callArguments = transformed.value;
61
+ appliedTransforms.push(...transformed.transforms);
62
+ }
63
+ let result = await client.callTool({ name: input.tool, arguments: callArguments }, undefined, { signal });
64
+ assertCallToolResult(input.server, input.tool, result);
65
+ if (result.isError) {
66
+ throw new ExternalMcpError("tool_failed", `External MCP ${input.server} tool ${input.tool} returned an upstream tool error.`);
67
+ }
68
+ if (transforms?.result) {
69
+ const transformed = await transforms.result(input.server, input.tool, result);
70
+ result = transformed.value;
71
+ appliedTransforms.push(...transformed.transforms);
72
+ assertCallToolResult(input.server, input.tool, result);
73
+ if (result.isError) {
74
+ throw new ExternalMcpError("tool_failed", `External MCP ${input.server} tool ${input.tool} returned an error after result transformation.`);
75
+ }
76
+ }
77
+ const projection = projectExternalMcpContent(input.server, input.tool, result, this.mediaMaxBytes);
78
+ return {
79
+ content: result.content,
80
+ value: {
81
+ operation: "call",
82
+ server: input.server,
83
+ tool: input.tool,
84
+ content: projection.content,
85
+ ...(!projection.hasMedia && isRecord(result.structuredContent)
86
+ ? { structuredContent: result.structuredContent }
87
+ : {}),
88
+ ...(appliedTransforms.length > 0 ? { transforms: appliedTransforms } : {}),
89
+ },
90
+ };
91
+ });
92
+ }
93
+ }
94
+ async withClient(name, signal, operation) {
95
+ const config = this.servers[name];
96
+ if (!config)
97
+ throw new ExternalMcpError("unknown_server", `Unknown configured external MCP server: ${name}.`);
98
+ const client = new Client({ name: "forgerelay-external-mcp", version: "1.0.0" });
99
+ const transport = createTransport(config);
100
+ try {
101
+ signal?.throwIfAborted();
102
+ await client.connect(transport);
103
+ signal?.throwIfAborted();
104
+ return await operation(client);
105
+ }
106
+ catch (error) {
107
+ if (error instanceof ExternalMcpError || error instanceof ExternalMcpTransformError)
108
+ throw error;
109
+ if (signal?.aborted)
110
+ signal.throwIfAborted();
111
+ throw new ExternalMcpError("transport_failed", `External MCP ${name} request failed.`);
112
+ }
113
+ finally {
114
+ await client.close().catch(() => undefined);
115
+ }
116
+ }
117
+ }
118
+ function createTransport(config) {
119
+ if (config.transport === "stdio") {
120
+ return new StdioClientTransport({
121
+ command: config.command,
122
+ ...(config.args ? { args: config.args } : {}),
123
+ ...(config.cwd ? { cwd: config.cwd } : {}),
124
+ ...(config.env
125
+ ? { env: { ...getDefaultEnvironment(), ...config.env } }
126
+ : {}),
127
+ stderr: "ignore",
128
+ });
129
+ }
130
+ return new StreamableHTTPClientTransport(new URL(config.url), {
131
+ ...(config.headers ? { requestInit: { headers: config.headers } } : {}),
132
+ });
133
+ }
134
+ async function discoverTools(client, signal) {
135
+ const tools = [];
136
+ let cursor;
137
+ for (let pageIndex = 0; pageIndex < MAX_TOOL_DISCOVERY_PAGES; pageIndex += 1) {
138
+ signal?.throwIfAborted();
139
+ const page = await client.listTools(cursor ? { cursor } : undefined, { signal });
140
+ const remaining = MAX_DISCOVERED_TOOLS - tools.length;
141
+ tools.push(...page.tools.slice(0, remaining));
142
+ if (page.tools.length > remaining)
143
+ return { tools, truncated: true };
144
+ cursor = page.nextCursor;
145
+ if (!cursor)
146
+ return { tools, truncated: false };
147
+ if (tools.length >= MAX_DISCOVERED_TOOLS)
148
+ return { tools, truncated: true };
149
+ }
150
+ return { tools, truncated: cursor !== undefined };
151
+ }
152
+ async function assertRegisteredTool(client, server, tool, signal) {
153
+ let cursor;
154
+ for (let pageIndex = 0; pageIndex < MAX_TOOL_DISCOVERY_PAGES; pageIndex += 1) {
155
+ signal?.throwIfAborted();
156
+ const page = await client.listTools(cursor ? { cursor } : undefined, { signal });
157
+ if (page.tools.some((candidate) => candidate.name === tool))
158
+ return;
159
+ cursor = page.nextCursor;
160
+ if (!cursor)
161
+ break;
162
+ }
163
+ throw new ExternalMcpError("unknown_tool", `External MCP ${server} does not advertise tool ${tool}.`);
164
+ }
165
+ function summarizeTool(tool) {
166
+ const serialized = safeJson(tool.inputSchema);
167
+ const schemaTruncated = Buffer.byteLength(serialized, "utf8") > MAX_TOOL_SCHEMA_BYTES;
168
+ return {
169
+ name: tool.name,
170
+ ...(tool.description
171
+ ? { description: tool.description.slice(0, MAX_TOOL_DESCRIPTION_CHARS) }
172
+ : {}),
173
+ inputSchema: schemaTruncated ? { type: "object" } : tool.inputSchema,
174
+ ...(schemaTruncated ? { schemaTruncated: true } : {}),
175
+ };
176
+ }
177
+ function projectExternalMcpContent(server, tool, result, mediaMaxBytes) {
178
+ const budget = createMediaBudget(mediaMaxBytes);
179
+ const content = [];
180
+ let hasMedia = false;
181
+ for (const entry of result.content ?? []) {
182
+ if (entry.type === "image") {
183
+ hasMedia = true;
184
+ if (!isSupportedImageMimeType(entry.mimeType)) {
185
+ throw new ExternalMcpError("media_unsupported", `External MCP ${server} tool ${tool} returned unsupported image MIME type ${entry.mimeType}.`);
186
+ }
187
+ const bytes = strictBase64ByteLength(entry.data);
188
+ if (bytes === undefined) {
189
+ throw new ExternalMcpError("media_malformed", `External MCP ${server} tool ${tool} returned malformed base64 image content.`);
190
+ }
191
+ try {
192
+ claimMediaBytes(budget, bytes);
193
+ }
194
+ catch {
195
+ throw new ExternalMcpError("media_too_large", `External MCP ${server} tool ${tool} returned media exceeding the configured ${mediaMaxBytes}-byte aggregate limit.`);
196
+ }
197
+ content.push({ type: "image", mimeType: entry.mimeType, bytes });
198
+ continue;
199
+ }
200
+ if (entry.type === "audio") {
201
+ throw new ExternalMcpError("media_unsupported", `External MCP ${server} tool ${tool} returned unsupported audio media content.`);
202
+ }
203
+ if (entry.type === "resource" && "blob" in entry.resource && typeof entry.resource.blob === "string") {
204
+ throw new ExternalMcpError("media_unsupported", `External MCP ${server} tool ${tool} returned unsupported binary resource content.`);
205
+ }
206
+ content.push(entry);
207
+ }
208
+ return { content, hasMedia };
209
+ }
210
+ function assertCallToolResult(server, tool, value) {
211
+ if (!isRecord(value)
212
+ || !Array.isArray(value.content)
213
+ || !CallToolResultSchema.safeParse(value).success) {
214
+ throw new ExternalMcpError("unsupported_result", `External MCP ${server} tool ${tool} returned an unsupported result shape.`);
215
+ }
216
+ }
217
+ function safeJson(value) {
218
+ try {
219
+ return JSON.stringify(value) ?? "";
220
+ }
221
+ catch {
222
+ return "";
223
+ }
224
+ }
225
+ function isRecord(value) {
226
+ return typeof value === "object" && value !== null && !Array.isArray(value);
227
+ }
@@ -0,0 +1,28 @@
1
+ export const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
2
+ export const SUPPORTED_IMAGE_MIME_TYPES = [
3
+ "image/png",
4
+ "image/jpeg",
5
+ "image/gif",
6
+ "image/webp",
7
+ ];
8
+ export function createMediaBudget(maxBytes) {
9
+ return { maxBytes, remainingBytes: maxBytes };
10
+ }
11
+ export function isSupportedImageMimeType(value) {
12
+ return SUPPORTED_IMAGE_MIME_TYPES.includes(value);
13
+ }
14
+ export function strictBase64ByteLength(value) {
15
+ if (value.length === 0 || value.length % 4 !== 0)
16
+ return undefined;
17
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
18
+ return undefined;
19
+ }
20
+ return Buffer.byteLength(value, "base64");
21
+ }
22
+ export function claimMediaBytes(budget, bytes) {
23
+ if (bytes > budget.remainingBytes) {
24
+ throw new Error(`Media content exceeds the configured per-result limit of ${budget.maxBytes} bytes ` +
25
+ `(${bytes} bytes requested, ${budget.remainingBytes} bytes remaining).`);
26
+ }
27
+ budget.remainingBytes -= bytes;
28
+ }
@@ -65,6 +65,6 @@ export function runActivityTool(lifecycle, workspace, conversationScopeId, tool,
65
65
  ...relation,
66
66
  });
67
67
  }
68
- export function runActivityToolWithHooks(lifecycle, hooks, workspace, conversationScopeId, request, hookOptions, relation = {}) {
69
- return runActivityTool(lifecycle, workspace, conversationScopeId, hookOptions.tool, request, () => runToolWithHooks(hooks, hookOptions), standardActivityOutcome, relation);
68
+ export function runActivityToolWithHooks(lifecycle, hooks, workspace, conversationScopeId, request, hookOptions, relation = {}, auditResult) {
69
+ return runActivityTool(lifecycle, workspace, conversationScopeId, hookOptions.tool, request, () => runToolWithHooks(hooks, hookOptions), standardActivityOutcome, relation, auditResult);
70
70
  }
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ const externalMcpInputSchema = z.discriminatedUnion("operation", [
3
+ z.object({ operation: z.literal("servers") }).strict(),
4
+ z.object({
5
+ operation: z.literal("tools"),
6
+ server: z.string().min(1),
7
+ }).strict(),
8
+ z.object({
9
+ operation: z.literal("call"),
10
+ server: z.string().min(1),
11
+ tool: z.string().min(1),
12
+ arguments: z.record(z.string(), z.unknown()).optional(),
13
+ }).strict(),
14
+ ]);
15
+ export function externalMcpCapabilityDefinitions(dependency) {
16
+ if (!dependency)
17
+ return [];
18
+ return [{
19
+ name: "mcp.external",
20
+ description: "Discover and call tools from user-configured external MCP servers through the ForgeRelay Capability gateway.",
21
+ guideName: "external-mcp",
22
+ readGuideBeforeFirstUse: true,
23
+ batchPolicy: "unsupported",
24
+ inputSchema: externalMcpInputSchema,
25
+ availability: () => ({
26
+ available: dependency.available,
27
+ reason: dependency.unavailableReason,
28
+ }),
29
+ run: async (input, context, options) => dependency.run(input, context, options),
30
+ }];
31
+ }
@@ -40,6 +40,13 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
40
40
  description: "Read-only semantic code navigation backed by external Language servers.",
41
41
  whenToRead: "Read before using code.intelligence or configuring Language servers.",
42
42
  },
43
+ {
44
+ name: "external-mcp",
45
+ directory: "host-integration/external-mcp",
46
+ description: "User-configured external MCP tool discovery and explicit forwarding through capability.",
47
+ whenToRead: "Read before discovering or calling a configured external MCP server.",
48
+ enabled: (config) => Object.keys(config.mcpServers).length > 0,
49
+ },
43
50
  {
44
51
  name: "workspace-tasks",
45
52
  directory: "workspace/workspace-tasks",
@@ -110,6 +117,9 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
110
117
  if (config.subagents) {
111
118
  capabilities.push("subagent.session");
112
119
  }
120
+ if (Object.keys(config.mcpServers).length > 0) {
121
+ capabilities.push("mcp.external");
122
+ }
113
123
  if (config.artifactsEnabled && context.artifactDownloadSupported) {
114
124
  capabilities.push("artifact.native-download");
115
125
  }
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { MAX_CODE_INTELLIGENCE_RESULT_LIMIT, } from "../../../lsp/code-intelligence-types.js";
3
3
  import { batchExecuteInputSchema, } from "../../operations/batch/types.js";
4
4
  import { workspaceCheckpointInputSchema, } from "./capabilities/workspace-checkpoint.js";
5
+ import { externalMcpCapabilityDefinitions, } from "./capabilities/external-mcp.js";
5
6
  export class CapabilityError extends Error {
6
7
  code;
7
8
  constructor(code, message) {
@@ -332,6 +333,7 @@ export function createCapabilityRegistry(dependencies) {
332
333
  run: async (input, context, options) => dependencies.workspaceTasks.run(input, context, options),
333
334
  }]
334
335
  : []),
336
+ ...externalMcpCapabilityDefinitions(dependencies.externalMcp),
335
337
  ...(dependencies.subagentSession
336
338
  ? [{
337
339
  name: "subagent.session",
@@ -52,6 +52,35 @@ export function contentText(content) {
52
52
  .map((item) => item.text)
53
53
  .join("\n");
54
54
  }
55
+ export function imageContentMetadata(content) {
56
+ const image = content.find((item) => item.type === "image");
57
+ return image
58
+ ? {
59
+ type: "image",
60
+ mimeType: image.mimeType,
61
+ bytes: Buffer.byteLength(image.data, "base64"),
62
+ }
63
+ : undefined;
64
+ }
65
+ export function metadataSafeContent(content) {
66
+ return content.filter((item) => item.type === "text");
67
+ }
68
+ export function auditToolResultWithoutImageData(result) {
69
+ if (typeof result !== "object" || result === null)
70
+ return result;
71
+ const record = result;
72
+ const content = Array.isArray(record.content) ? record.content : undefined;
73
+ if (!content)
74
+ return result;
75
+ const media = imageContentMetadata(content);
76
+ if (!media)
77
+ return result;
78
+ return {
79
+ ...record,
80
+ content: metadataSafeContent(content),
81
+ media,
82
+ };
83
+ }
55
84
  export function toolErrorPreview(content) {
56
85
  const text = contentText(content).replace(/\s+/g, " ").trim();
57
86
  if (!text)
@@ -5,13 +5,14 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
5
5
  import * as z from "zod/v4";
6
6
  import { applyPatch } from "../../../filesystem/apply-patch.js";
7
7
  import { readFileTool } from "../../../filesystem/filesystem-tools.js";
8
+ import { createMediaBudget } from "../../../operations/media-content.js";
8
9
  import { toolNames } from "../../../server-instructions.js";
9
10
  import { executeBulkRead } from "../../../operations/bulk-read.js";
10
11
  import { formatPathForPrompt } from "../../../../workspaces/resources/skills.js";
11
12
  import { activityRelationFor, activityRequestFor, runActivityTool, runActivityToolWithHooks, } from "../../core/activity-support.js";
12
13
  import { workspaceHookInvocation } from "../../core/capability-support.js";
13
14
  import { resultOutputSchema, workspaceAgentsFileOutputSchema } from "../../core/schemas.js";
14
- import { contentText, logToolCall, textBlock, toolResultAgentsFiles, toolResultContent, toolResultIsError, toolResultText, workspaceLogContext, } from "../../core/tool-support.js";
15
+ import { contentText, imageContentMetadata, logToolCall, textBlock, toolResultAgentsFiles, toolResultContent, toolResultIsError, toolResultText, workspaceLogContext, } from "../../core/tool-support.js";
15
16
  import { appendAutomaticMutationDiagnostics } from "./mutation-diagnostics.js";
16
17
  const WRITE_TOOL_ANNOTATIONS = {
17
18
  readOnlyHint: false,
@@ -25,6 +26,11 @@ const EDIT_TOOL_ANNOTATIONS = {
25
26
  idempotentHint: false,
26
27
  openWorldHint: false,
27
28
  };
29
+ const MEDIA_METADATA_OUTPUT_SCHEMA = z.object({
30
+ type: z.literal("image"),
31
+ mimeType: z.string(),
32
+ bytes: z.number().int().nonnegative(),
33
+ });
28
34
  export function registerFilesystemTools(options) {
29
35
  const { server, config, workspaces, compositeWorkspaces, compositeTaskGuides, remoteWorkspaces, coreOperations, nativeBulkMutations, activityLifecycle, codeIntelligence, hooks, toolDescriptions, resolveExecutionTarget, prepareExecutionContext, presentSemanticWorkResult, hostScopeIdFor, } = options;
30
36
  registerAppTool(server, toolNames.read, {
@@ -66,10 +72,12 @@ export function registerFilesystemTools(options) {
66
72
  },
67
73
  outputSchema: resultOutputSchema({
68
74
  agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
75
+ media: MEDIA_METADATA_OUTPUT_SCHEMA.optional(),
69
76
  results: z.array(z.object({
70
77
  path: z.string(),
71
78
  status: z.enum(["done", "error"]),
72
79
  result: z.string(),
80
+ media: MEDIA_METADATA_OUTPUT_SCHEMA.optional(),
73
81
  })).optional(),
74
82
  files: z.number().int().nonnegative().optional(),
75
83
  failed: z.number().int().nonnegative().optional(),
@@ -90,23 +98,32 @@ export function registerFilesystemTools(options) {
90
98
  }
91
99
  }
92
100
  const startedAt = performance.now();
93
- const children = await Promise.all(requestedPaths.map(async (requestedPath) => {
94
- const response = await readFileTool({ path: requestedPath, offset, limit }, {
101
+ const mediaBudget = createMediaBudget(config.mediaMaxBytes);
102
+ const execution = await executeBulkRead({
103
+ paths: requestedPaths,
104
+ signal: extra.signal,
105
+ run: (requestedPath) => readFileTool({ path: requestedPath, offset, limit }, {
95
106
  cwd: process.cwd(),
96
107
  root: config.allowedRoots[0] ?? process.cwd(),
97
108
  readRoots: config.allowedRoots,
98
- });
99
- return {
100
- path: requestedPath,
101
- status: response.isError ? "error" : "done",
102
- response,
103
- result: contentText(response.content),
104
- };
105
- }));
106
- const failed = children.filter((child) => child.status === "error").length;
107
- const content = children.flatMap((child) => requestedPaths.length === 1
108
- ? child.response.content
109
- : [textBlock(`--- ${child.path} · ${child.status} ---`), ...child.response.content]);
109
+ mediaBudget,
110
+ }),
111
+ isError: (response) => response.isError === true,
112
+ resultText: (response) => contentText(response.content),
113
+ });
114
+ const children = execution.children;
115
+ const failed = execution.failed;
116
+ const content = children.flatMap((child) => {
117
+ const childContent = child.response
118
+ ? child.response.content
119
+ : [textBlock(child.result)];
120
+ return requestedPaths.length === 1
121
+ ? childContent
122
+ : [textBlock(`--- ${child.path} · ${child.status} ---`), ...childContent];
123
+ });
124
+ const singleMedia = requestedPaths.length === 1 && children[0]?.response
125
+ ? imageContentMetadata(children[0].response.content)
126
+ : undefined;
110
127
  logToolCall(config, {
111
128
  tool: toolNames.read,
112
129
  path: requestedPaths.length === 1 ? requestedPaths[0] : `${requestedPaths.length} unscoped files`,
@@ -118,9 +135,20 @@ export function registerFilesystemTools(options) {
118
135
  ...(failed > 0 ? { isError: true } : {}),
119
136
  structuredContent: {
120
137
  result: contentText(content),
138
+ ...(singleMedia ? { media: singleMedia } : {}),
121
139
  ...(requestedPaths.length > 1
122
140
  ? {
123
- results: children.map(({ path: childPath, status, result }) => ({ path: childPath, status, result })),
141
+ results: children.map(({ path: childPath, status, result, response }) => {
142
+ const media = response
143
+ ? imageContentMetadata(response.content)
144
+ : undefined;
145
+ return {
146
+ path: childPath,
147
+ status,
148
+ result,
149
+ ...(media ? { media } : {}),
150
+ };
151
+ }),
124
152
  files: children.length,
125
153
  failed,
126
154
  }
@@ -163,6 +191,7 @@ export function registerFilesystemTools(options) {
163
191
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
164
192
  let response;
165
193
  await runActivityTool(activityLifecycle, workspace, hostScopeIdFor(extra._meta, extra.sessionId), toolNames.read, activityRequestFor({ workspaceId: executionWorkspaceId, paths, offset, limit }, executionContext), async (parentContext) => {
194
+ const mediaBudget = createMediaBudget(config.mediaMaxBytes);
166
195
  const execution = await executeBulkRead({
167
196
  paths: paths,
168
197
  signal: extra.signal,
@@ -170,6 +199,7 @@ export function registerFilesystemTools(options) {
170
199
  ...executionContext,
171
200
  parentActivityId: parentContext.activityId,
172
201
  turnId: parentContext.turnId,
202
+ mediaBudget,
173
203
  }),
174
204
  isError: toolResultIsError,
175
205
  resultText: toolResultText,
@@ -191,11 +221,17 @@ export function registerFilesystemTools(options) {
191
221
  content,
192
222
  structuredContent: {
193
223
  result: contentText(content),
194
- results: execution.children.map(({ path: childPath, status, result }) => ({
195
- path: childPath,
196
- status,
197
- result,
198
- })),
224
+ results: execution.children.map(({ path: childPath, status, result, response }) => {
225
+ const media = response
226
+ ? imageContentMetadata(toolResultContent(response))
227
+ : undefined;
228
+ return {
229
+ path: childPath,
230
+ status,
231
+ result,
232
+ ...(media ? { media } : {}),
233
+ };
234
+ }),
199
235
  files: execution.children.length,
200
236
  failed: execution.failed,
201
237
  ...(agentsFiles.length > 0 ? { agentsFiles } : {}),
@@ -1,6 +1,7 @@
1
1
  import { CapabilityError } from "../../core/capability-registry.js";
2
2
  import { deletePath, renamePath } from "../../../filesystem/file-mutations.js";
3
3
  import { editFileTool, readFileTool, writeFileTool } from "../../../filesystem/filesystem-tools.js";
4
+ import { createMediaBudget } from "../../../operations/media-content.js";
4
5
  import { runToolWithHooks } from "../../../hooks/hooks.js";
5
6
  import { toolNames } from "../../../server-instructions.js";
6
7
  import { BatchExecutor } from "../../../operations/batch/executor.js";
@@ -11,7 +12,7 @@ import { markReturnedOutput, processActivityOutcome, processToolResponse, } from
11
12
  import { capabilityActivityAuditRequest, capabilityActivityAuditResult } from "../../../../subagents/sessions/mcp/audit.js";
12
13
  import { activityRelationFor, activityRequestFor, runActivityTool, runActivityToolWithHooks, standardActivityOutcome, } from "../../core/activity-support.js";
13
14
  import { capabilityContextFor, managedWorktreeRecoveryCapabilityContext, workspaceHookInvocation, } from "../../core/capability-support.js";
14
- import { assertWorkspaceInstructionsLoadedBeforeSideEffect, contentLineCount, contentText, countDiffStats, formatDiscoveredWorkspaceInstructions, logFailedToolResponse, logToolCall, newFilePatch, textBlock, textSummary, toolResultContent, toolResultIsError, toolResultText, workspaceLogContext, } from "../../core/tool-support.js";
15
+ import { assertWorkspaceInstructionsLoadedBeforeSideEffect, auditToolResultWithoutImageData, contentLineCount, contentText, countDiffStats, formatDiscoveredWorkspaceInstructions, imageContentMetadata, logFailedToolResponse, logToolCall, metadataSafeContent, newFilePatch, textBlock, textSummary, toolResultContent, toolResultIsError, toolResultText, workspaceLogContext, } from "../../core/tool-support.js";
15
16
  import { appendAutomaticMutationDiagnostics } from "./mutation-diagnostics.js";
16
17
  export function createOperationRuntime(options) {
17
18
  const { config, workspaces, activityLifecycle, hooks, processSessions, bashOutputStore, capabilityRegistry, codeIntelligence, hostScopeIdFor, } = options;
@@ -33,6 +34,7 @@ export function createOperationRuntime(options) {
33
34
  cwd: workspace.root,
34
35
  root: workspace.root,
35
36
  readRoots: readPath.readRoots,
37
+ mediaBudget: context.mediaBudget ?? createMediaBudget(config.mediaMaxBytes),
36
38
  });
37
39
  if (response.isError) {
38
40
  logFailedToolResponse(config, {
@@ -49,10 +51,12 @@ export function createOperationRuntime(options) {
49
51
  const content = discoveredInstructionContent
50
52
  ? [discoveredInstructionContent, ...response.content]
51
53
  : response.content;
54
+ const media = imageContentMetadata(response.content);
52
55
  const summary = {
53
56
  ...textSummary(response.content),
54
57
  offset: readInput.offset ?? 1,
55
58
  limited: readInput.limit !== undefined,
59
+ ...(media ? { media } : {}),
56
60
  };
57
61
  logToolCall(config, {
58
62
  tool: toolNames.read,
@@ -70,11 +74,12 @@ export function createOperationRuntime(options) {
70
74
  workspaceId,
71
75
  path: readInput.path,
72
76
  summary,
73
- payload: { content: response.content },
77
+ payload: { content: metadataSafeContent(response.content) },
74
78
  },
75
79
  },
76
80
  structuredContent: {
77
81
  result: contentText(content),
82
+ ...(media ? { media } : {}),
78
83
  ...(discoveredInstructions.length > 0
79
84
  ? {
80
85
  agentsFiles: discoveredInstructions.map((file) => ({
@@ -86,7 +91,7 @@ export function createOperationRuntime(options) {
86
91
  },
87
92
  };
88
93
  },
89
- }, activityRelationFor(context));
94
+ }, activityRelationFor(context), auditToolResultWithoutImageData);
90
95
  },
91
96
  write: async (input, context) => {
92
97
  const { workspaceId, ...writeInput } = input;
@@ -503,8 +508,10 @@ export function createOperationRuntime(options) {
503
508
  activityId: activityContext.activityId,
504
509
  });
505
510
  changedPaths = execution.changedPaths ?? [];
511
+ const transientContent = execution.content;
506
512
  const result = {
507
- content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
513
+ content: transientContent
514
+ ?? [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
508
515
  ...(execution.card
509
516
  ? {
510
517
  _meta: {
@@ -59,7 +59,7 @@ export function createHttpServer(config, options, createMcpServer) {
59
59
  });
60
60
  const workspaceStore = createWorkspaceStore(config.stateDir);
61
61
  const workspaces = new WorkspaceRegistry(config, workspaceStore);
62
- const sharedRemoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir);
62
+ const sharedRemoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir, config.mediaMaxBytes);
63
63
  const sharedCompositeWorkspaces = new CompositeWorkspaceRegistry(config.stateDir);
64
64
  const sharedWorkspaceTasks = new WorkspaceTaskStore(config.stateDir);
65
65
  const sharedTaskReminders = new WorkspaceTaskReminderTracker(config.taskReminderInterval, sharedWorkspaceTasks);
@@ -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),