@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,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
|
|
94
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 }) =>
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
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),
|