@akira-tl/forgerelay 0.2.3 → 0.2.5
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 +24 -0
- package/dist/hooks.js +18 -4
- package/dist/logger.js +56 -2
- package/dist/mcp/server-instructions.js +10 -8
- package/dist/mcp-app-template.js +45 -0
- package/dist/pi-tools.js +1 -9
- package/dist/process-sessions.js +56 -8
- package/dist/roots.js +1 -1
- package/dist/server.js +324 -175
- package/dist/workspace-store.js +13 -0
- package/dist/workspaces.js +275 -25
- package/docs/chatgpt-coding-workflow.md +18 -1
- package/docs/configuration.md +25 -4
- package/docs/debugging.md +35 -7
- package/docs/gotchas.md +22 -1
- package/docs/roadmap.md +26 -0
- package/docs/security.md +10 -0
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +82 -4
- package/scripts/debug/runtime.mjs +2 -1
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { access, realpath } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
7
|
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
8
8
|
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
9
9
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
@@ -21,12 +21,13 @@ import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
|
|
|
21
21
|
import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
|
|
22
22
|
import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
|
|
23
23
|
import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, workspaceLogLabel, } from "./logger.js";
|
|
24
|
-
import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool,
|
|
24
|
+
import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
|
|
25
25
|
import { SingleUserOAuthProvider } from "./oauth-provider.js";
|
|
26
26
|
import { McpSessionRegistry, } from "./mcp-sessions.js";
|
|
27
|
-
import { ProcessSessionManager } from "./process-sessions.js";
|
|
27
|
+
import { ProcessSessionManager, } from "./process-sessions.js";
|
|
28
28
|
import { createReviewCheckpointManager } from "./review-checkpoints.js";
|
|
29
29
|
import { openAiConversationScopeId } from "./request-meta.js";
|
|
30
|
+
import { readWorkspaceAppManifestEntry, resolveWorkspaceAppIdentity, WORKSPACE_APP_LEGACY_URI, WORKSPACE_APP_URI_TEMPLATE, } from "./mcp-app-template.js";
|
|
30
31
|
import { shutdownHttpServer } from "./server-shutdown.js";
|
|
31
32
|
import { formatPathForPrompt } from "./skills.js";
|
|
32
33
|
import { createWorkspaceStore } from "./workspace-store.js";
|
|
@@ -38,8 +39,6 @@ import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvail
|
|
|
38
39
|
const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
|
|
39
40
|
const FORGERELAY_VERSION = readForgeRelayVersion();
|
|
40
41
|
const MCP_SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
|
|
41
|
-
const WORKSPACE_APP_URI = "ui://forgerelay/workspace-app.html";
|
|
42
|
-
const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html";
|
|
43
42
|
const WRITE_TOOL_ANNOTATIONS = {
|
|
44
43
|
readOnlyHint: false,
|
|
45
44
|
destructiveHint: true,
|
|
@@ -71,20 +70,21 @@ function shouldAttachWidget(mode, kind) {
|
|
|
71
70
|
function toolWidgetDescriptorMeta(config, kind) {
|
|
72
71
|
if (!shouldAttachWidget(config.widgets, kind))
|
|
73
72
|
return { _meta: {} };
|
|
73
|
+
const resourceUri = currentWorkspaceAppIdentity().uri;
|
|
74
74
|
return {
|
|
75
75
|
_meta: {
|
|
76
76
|
ui: {
|
|
77
|
-
resourceUri
|
|
78
|
-
visibility: ["model"],
|
|
77
|
+
resourceUri,
|
|
78
|
+
visibility: ["model", "app"],
|
|
79
79
|
},
|
|
80
|
+
"openai/outputTemplate": resourceUri,
|
|
80
81
|
},
|
|
81
82
|
};
|
|
82
83
|
}
|
|
83
|
-
function workspaceLogContext(workspace,
|
|
84
|
+
function workspaceLogContext(workspace, _sessionId) {
|
|
84
85
|
return {
|
|
85
86
|
workspaceId: workspace.id,
|
|
86
87
|
workspace: workspaceLogLabel(workspace.root, workspace.id),
|
|
87
|
-
session: sessionIdPrefix(sessionId),
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
function formatVisibleAgent(agent) {
|
|
@@ -161,6 +161,23 @@ function requestLogFields(req, config) {
|
|
|
161
161
|
contentLength: req.header("content-length"),
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
|
+
function mcpRequestDebugFields(body) {
|
|
165
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
166
|
+
return {};
|
|
167
|
+
const request = body;
|
|
168
|
+
const rpcMethod = typeof request.method === "string" ? request.method : undefined;
|
|
169
|
+
const params = request.params && typeof request.params === "object" && !Array.isArray(request.params)
|
|
170
|
+
? request.params
|
|
171
|
+
: undefined;
|
|
172
|
+
let rpcTarget;
|
|
173
|
+
if (rpcMethod === "resources/read" && typeof params?.uri === "string") {
|
|
174
|
+
rpcTarget = params.uri;
|
|
175
|
+
}
|
|
176
|
+
else if (rpcMethod === "tools/call" && typeof params?.name === "string") {
|
|
177
|
+
rpcTarget = params.name;
|
|
178
|
+
}
|
|
179
|
+
return { rpcMethod, rpcTarget };
|
|
180
|
+
}
|
|
164
181
|
function logToolCall(config, fields) {
|
|
165
182
|
if (!config.logging.toolCalls)
|
|
166
183
|
return;
|
|
@@ -247,16 +264,20 @@ function assetBaseUrl(config) {
|
|
|
247
264
|
function uiManifestUrl() {
|
|
248
265
|
return new URL("../dist/ui/.vite/manifest.json", import.meta.url);
|
|
249
266
|
}
|
|
250
|
-
function
|
|
251
|
-
return
|
|
267
|
+
function uiBuildDirectoryUrl() {
|
|
268
|
+
return new URL("../dist/ui/", import.meta.url);
|
|
269
|
+
}
|
|
270
|
+
let cachedWorkspaceAppIdentity;
|
|
271
|
+
function currentWorkspaceAppIdentity() {
|
|
272
|
+
cachedWorkspaceAppIdentity ??= resolveWorkspaceAppIdentity({
|
|
273
|
+
manifestUrl: uiManifestUrl(),
|
|
274
|
+
buildDirectoryUrl: uiBuildDirectoryUrl(),
|
|
275
|
+
fallbackRevision: FORGERELAY_VERSION,
|
|
276
|
+
});
|
|
277
|
+
return cachedWorkspaceAppIdentity;
|
|
252
278
|
}
|
|
253
279
|
function getWorkspaceAppManifestEntry() {
|
|
254
|
-
|
|
255
|
-
const entry = manifest[WORKSPACE_APP_MANIFEST_ENTRY];
|
|
256
|
-
if (!entry?.file) {
|
|
257
|
-
throw new Error(`Missing ${WORKSPACE_APP_MANIFEST_ENTRY} in UI manifest.`);
|
|
258
|
-
}
|
|
259
|
-
return entry;
|
|
280
|
+
return readWorkspaceAppManifestEntry(uiManifestUrl());
|
|
260
281
|
}
|
|
261
282
|
function assetUrl(baseUrl, assetPath) {
|
|
262
283
|
return `${baseUrl}/${assetPath.replace(/^\/+/, "")}`;
|
|
@@ -306,6 +327,51 @@ async function assertWorkspaceAppAssets() {
|
|
|
306
327
|
await access(candidate);
|
|
307
328
|
}
|
|
308
329
|
}
|
|
330
|
+
function workspaceAppCompatibilityKind(requestedUri, currentUri) {
|
|
331
|
+
if (requestedUri === currentUri)
|
|
332
|
+
return "current";
|
|
333
|
+
if (requestedUri === WORKSPACE_APP_LEGACY_URI)
|
|
334
|
+
return "legacy";
|
|
335
|
+
return "historical";
|
|
336
|
+
}
|
|
337
|
+
async function readWorkspaceAppResource(config, requestedUri, transportSessionId) {
|
|
338
|
+
const currentUri = currentWorkspaceAppIdentity().uri;
|
|
339
|
+
const compatibility = workspaceAppCompatibilityKind(requestedUri, currentUri);
|
|
340
|
+
try {
|
|
341
|
+
await assertWorkspaceAppAssets();
|
|
342
|
+
const result = {
|
|
343
|
+
contents: [
|
|
344
|
+
{
|
|
345
|
+
uri: requestedUri,
|
|
346
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
347
|
+
text: workspaceAppHtml(config),
|
|
348
|
+
_meta: {
|
|
349
|
+
ui: {
|
|
350
|
+
csp: appCsp(config),
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
logEvent(config.logging, "debug", "mcp_app_template_read", {
|
|
357
|
+
requestedUri,
|
|
358
|
+
currentUri,
|
|
359
|
+
compatibility,
|
|
360
|
+
sessionIdPrefix: sessionIdPrefix(transportSessionId),
|
|
361
|
+
});
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
logEvent(config.logging, "warn", "mcp_app_template_read_failed", {
|
|
366
|
+
requestedUri,
|
|
367
|
+
currentUri,
|
|
368
|
+
compatibility,
|
|
369
|
+
error: error instanceof Error ? error.message : String(error),
|
|
370
|
+
sessionIdPrefix: sessionIdPrefix(transportSessionId),
|
|
371
|
+
});
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
309
375
|
function processResult(snapshot) {
|
|
310
376
|
const status = snapshot.running
|
|
311
377
|
? `Process running with session ID ${snapshot.sessionId}.`
|
|
@@ -314,6 +380,45 @@ function processResult(snapshot) {
|
|
|
314
380
|
: `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
|
|
315
381
|
return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status;
|
|
316
382
|
}
|
|
383
|
+
function completedProcessResult(snapshot) {
|
|
384
|
+
const status = snapshot.signal
|
|
385
|
+
? `Background process ${snapshot.sessionId} exited after signal ${snapshot.signal}.`
|
|
386
|
+
: `Background process ${snapshot.sessionId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
|
|
387
|
+
const command = `Command: ${snapshot.command}`;
|
|
388
|
+
const output = snapshot.output ? `\n${snapshot.output.replace(/\n$/, "")}` : "";
|
|
389
|
+
return `${status}\n${command}${output}`;
|
|
390
|
+
}
|
|
391
|
+
function attachCompletedProcessNotices(processSessions, workspaceId, result) {
|
|
392
|
+
if (result instanceof Error) {
|
|
393
|
+
const completed = processSessions.takeCompleted(workspaceId);
|
|
394
|
+
if (completed.length > 0) {
|
|
395
|
+
result.message = [
|
|
396
|
+
result.message,
|
|
397
|
+
...completed.map((snapshot) => completedProcessResult(snapshot)),
|
|
398
|
+
].join("\n\n");
|
|
399
|
+
}
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
if (typeof result !== "object" || result === null)
|
|
403
|
+
return result;
|
|
404
|
+
const content = result.content;
|
|
405
|
+
if (!Array.isArray(content))
|
|
406
|
+
return result;
|
|
407
|
+
const structured = result.structuredContent;
|
|
408
|
+
const currentSessionId = structured?.running === true && typeof structured.sessionId === "number"
|
|
409
|
+
? structured.sessionId
|
|
410
|
+
: undefined;
|
|
411
|
+
const completed = processSessions.takeCompleted(workspaceId, undefined, currentSessionId);
|
|
412
|
+
if (completed.length === 0)
|
|
413
|
+
return result;
|
|
414
|
+
return {
|
|
415
|
+
...result,
|
|
416
|
+
content: [
|
|
417
|
+
...content,
|
|
418
|
+
...completed.map((snapshot) => textBlock(completedProcessResult(snapshot))),
|
|
419
|
+
],
|
|
420
|
+
};
|
|
421
|
+
}
|
|
317
422
|
function processOutputSchema() {
|
|
318
423
|
return resultOutputSchema({
|
|
319
424
|
sessionId: z.number().optional(),
|
|
@@ -367,86 +472,89 @@ function workspaceHookInvocation(workspace) {
|
|
|
367
472
|
function toolResultIsError(result) {
|
|
368
473
|
return typeof result === "object" && result !== null && result.isError === true;
|
|
369
474
|
}
|
|
370
|
-
function
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
outputSchema: processOutputSchema(),
|
|
403
|
-
...toolWidgetDescriptorMeta(config, "shell"),
|
|
404
|
-
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
405
|
-
}, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }, extra) => {
|
|
406
|
-
const workspace = workspaces.getWorkspace(workspaceId);
|
|
407
|
-
return runToolWithHooks(hooks, {
|
|
408
|
-
tool: "exec_command",
|
|
409
|
-
invocation: workspaceHookInvocation(workspace),
|
|
410
|
-
payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
|
|
411
|
-
operation: async () => {
|
|
412
|
-
const startedAt = performance.now();
|
|
413
|
-
const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
|
|
414
|
-
const snapshot = await processSessions.start({
|
|
415
|
-
workspaceId,
|
|
416
|
-
command: cmd,
|
|
417
|
-
cwd,
|
|
418
|
-
workspaceRoot: workspace.root,
|
|
419
|
-
tty,
|
|
420
|
-
columns,
|
|
421
|
-
rows,
|
|
422
|
-
yieldTimeMs,
|
|
423
|
-
maxOutputTokens,
|
|
424
|
-
});
|
|
425
|
-
logToolCall(config, {
|
|
426
|
-
tool: "exec_command",
|
|
427
|
-
...workspaceLogContext(workspace, extra.sessionId),
|
|
428
|
-
workingDirectory: workingDirectory ?? ".",
|
|
429
|
-
command: cmd,
|
|
430
|
-
commandLength: cmd.length,
|
|
431
|
-
exitCode: snapshot.exitCode,
|
|
432
|
-
running: snapshot.running,
|
|
433
|
-
processSessionId: snapshot.sessionId,
|
|
434
|
-
success: snapshot.running || snapshot.exitCode === 0,
|
|
435
|
-
durationMs: Math.round(performance.now() - startedAt),
|
|
436
|
-
});
|
|
437
|
-
return processToolResponse("exec_command", workspaceId, snapshot, {
|
|
438
|
-
command: cmd,
|
|
439
|
-
workingDirectory: workingDirectory ?? ".",
|
|
440
|
-
running: snapshot.running,
|
|
441
|
-
exitCode: snapshot.exitCode,
|
|
442
|
-
wallTimeMs: snapshot.wallTimeMs,
|
|
443
|
-
});
|
|
475
|
+
function registerProcessTools(server, config, workspaces, processSessions, hooks) {
|
|
476
|
+
if (config.toolMode === "codex") {
|
|
477
|
+
registerAppTool(server, "exec_command", {
|
|
478
|
+
title: "Execute command",
|
|
479
|
+
description: `Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId for write_stdin. Use this for file inspection, tests, builds, package scripts, generators, formatters, and long-running processes. ${buildShellMutationPolicy()} Call open_workspace first and pass workspaceId.`,
|
|
480
|
+
inputSchema: {
|
|
481
|
+
workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
|
|
482
|
+
cmd: z.string().min(1).describe("Shell command to execute."),
|
|
483
|
+
tty: z
|
|
484
|
+
.boolean()
|
|
485
|
+
.optional()
|
|
486
|
+
.describe("Allocate a pseudo-terminal for interactive commands. Defaults to false."),
|
|
487
|
+
columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width. Defaults to 80."),
|
|
488
|
+
rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height. Defaults to 24."),
|
|
489
|
+
workingDirectory: z
|
|
490
|
+
.string()
|
|
491
|
+
.optional()
|
|
492
|
+
.describe("Working directory relative to the workspace root. Defaults to the workspace root."),
|
|
493
|
+
yieldTimeMs: z
|
|
494
|
+
.number()
|
|
495
|
+
.int()
|
|
496
|
+
.min(0)
|
|
497
|
+
.max(30_000)
|
|
498
|
+
.optional()
|
|
499
|
+
.describe("Milliseconds to wait before returning a running session. Defaults to 10000."),
|
|
500
|
+
maxOutputTokens: z
|
|
501
|
+
.number()
|
|
502
|
+
.int()
|
|
503
|
+
.positive()
|
|
504
|
+
.max(100_000)
|
|
505
|
+
.optional()
|
|
506
|
+
.describe("Approximate output token budget. Defaults to 10000."),
|
|
444
507
|
},
|
|
508
|
+
outputSchema: processOutputSchema(),
|
|
509
|
+
...toolWidgetDescriptorMeta(config, "shell"),
|
|
510
|
+
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
511
|
+
}, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }, extra) => {
|
|
512
|
+
const workspace = workspaces.getWorkspace(workspaceId);
|
|
513
|
+
return runToolWithHooks(hooks, {
|
|
514
|
+
tool: "exec_command",
|
|
515
|
+
invocation: workspaceHookInvocation(workspace),
|
|
516
|
+
payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
|
|
517
|
+
operation: async () => {
|
|
518
|
+
const startedAt = performance.now();
|
|
519
|
+
const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
|
|
520
|
+
const snapshot = await processSessions.start({
|
|
521
|
+
workspaceId,
|
|
522
|
+
command: cmd,
|
|
523
|
+
cwd,
|
|
524
|
+
workspaceRoot: workspace.root,
|
|
525
|
+
tty,
|
|
526
|
+
columns,
|
|
527
|
+
rows,
|
|
528
|
+
yieldTimeMs,
|
|
529
|
+
maxOutputTokens,
|
|
530
|
+
codexCi: true,
|
|
531
|
+
});
|
|
532
|
+
logToolCall(config, {
|
|
533
|
+
tool: "exec_command",
|
|
534
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
535
|
+
workingDirectory: workingDirectory ?? ".",
|
|
536
|
+
command: cmd,
|
|
537
|
+
commandLength: cmd.length,
|
|
538
|
+
exitCode: snapshot.exitCode,
|
|
539
|
+
running: snapshot.running,
|
|
540
|
+
processSessionId: snapshot.sessionId,
|
|
541
|
+
success: snapshot.running || snapshot.exitCode === 0,
|
|
542
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
543
|
+
});
|
|
544
|
+
return processToolResponse("exec_command", workspaceId, snapshot, {
|
|
545
|
+
command: cmd,
|
|
546
|
+
workingDirectory: workingDirectory ?? ".",
|
|
547
|
+
running: snapshot.running,
|
|
548
|
+
exitCode: snapshot.exitCode,
|
|
549
|
+
wallTimeMs: snapshot.wallTimeMs,
|
|
550
|
+
});
|
|
551
|
+
},
|
|
552
|
+
});
|
|
445
553
|
});
|
|
446
|
-
}
|
|
554
|
+
}
|
|
447
555
|
registerAppTool(server, "write_stdin", {
|
|
448
556
|
title: "Write to process",
|
|
449
|
-
description: "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll.
|
|
557
|
+
description: "Poll or write characters to a running process returned by bash or exec_command. Omit chars or pass an empty string to poll. Waiting never kills the process; pass \\u0003 to explicitly send Ctrl-C.",
|
|
450
558
|
inputSchema: {
|
|
451
559
|
workspaceId: z.string().describe("Workspace identifier used to start the process."),
|
|
452
560
|
sessionId: z.number().describe("Process session identifier returned by exec_command."),
|
|
@@ -457,9 +565,9 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
|
|
|
457
565
|
.number()
|
|
458
566
|
.int()
|
|
459
567
|
.min(0)
|
|
460
|
-
.max(
|
|
568
|
+
.max(300_000)
|
|
461
569
|
.optional()
|
|
462
|
-
.describe("Milliseconds to
|
|
570
|
+
.describe("Milliseconds to keep waiting before returning again, max 300000. Polling defaults to 5000; interaction defaults to 250."),
|
|
463
571
|
maxOutputTokens: z
|
|
464
572
|
.number()
|
|
465
573
|
.int()
|
|
@@ -515,7 +623,7 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
|
|
|
515
623
|
}
|
|
516
624
|
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
|
|
517
625
|
const toolDescriptions = buildToolDescriptions(config);
|
|
518
|
-
const hooks = new HookRunner(config.hooks, config.logging);
|
|
626
|
+
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
|
|
519
627
|
const server = new McpServer({
|
|
520
628
|
name: "forgerelay",
|
|
521
629
|
title: "ForgeRelay",
|
|
@@ -526,37 +634,33 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
526
634
|
artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
|
|
527
635
|
}),
|
|
528
636
|
});
|
|
529
|
-
|
|
637
|
+
const currentWorkspaceAppUri = currentWorkspaceAppIdentity().uri;
|
|
638
|
+
const workspaceAppResourceMetadata = {
|
|
530
639
|
description: "Interactive card for viewing ForgeRelay file diffs.",
|
|
531
640
|
_meta: {
|
|
532
641
|
ui: {
|
|
533
642
|
csp: appCsp(config),
|
|
534
643
|
},
|
|
535
644
|
},
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
text: workspaceAppHtml(config),
|
|
544
|
-
_meta: {
|
|
545
|
-
ui: {
|
|
546
|
-
csp: appCsp(config),
|
|
547
|
-
},
|
|
548
|
-
},
|
|
549
|
-
},
|
|
550
|
-
],
|
|
551
|
-
};
|
|
552
|
-
});
|
|
645
|
+
};
|
|
646
|
+
registerAppResource(server, "ForgeRelay Diff Card", currentWorkspaceAppUri, workspaceAppResourceMetadata, async (uri, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
|
|
647
|
+
registerAppResource(server, "ForgeRelay Diff Card legacy", WORKSPACE_APP_LEGACY_URI, workspaceAppResourceMetadata, async (uri, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
|
|
648
|
+
server.registerResource("ForgeRelay Diff Card compatibility", new ResourceTemplate(WORKSPACE_APP_URI_TEMPLATE, { list: undefined }), {
|
|
649
|
+
...workspaceAppResourceMetadata,
|
|
650
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
651
|
+
}, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
|
|
553
652
|
registerAppTool(server, "open_workspace", {
|
|
554
653
|
title: "Open workspace",
|
|
555
|
-
description: "Open a local
|
|
654
|
+
description: "Open or resume a local coding workspace. A conversation keeps a stable workspaceId for a project, while different conversations normally receive different logical workspaceIds that may point at the same physical checkout or worktree. Pass workspaceId to explicitly resume an existing logical workspace in this conversation. Default to checkout mode and only use mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. Workspaces idle for more than two days are reported for user-directed cleanup or resumption.",
|
|
556
655
|
inputSchema: {
|
|
557
656
|
path: z
|
|
558
657
|
.string()
|
|
559
|
-
.
|
|
658
|
+
.optional()
|
|
659
|
+
.describe("Project path to open. Required unless workspaceId is supplied. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
|
|
660
|
+
workspaceId: z
|
|
661
|
+
.string()
|
|
662
|
+
.optional()
|
|
663
|
+
.describe("Existing logical workspace ID to resume in this conversation. When supplied, ForgeRelay resumes that workspace rather than allocating another ID."),
|
|
560
664
|
mode: z
|
|
561
665
|
.enum(["checkout", "worktree"])
|
|
562
666
|
.optional()
|
|
@@ -568,7 +672,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
568
672
|
newWorktree: z
|
|
569
673
|
.boolean()
|
|
570
674
|
.optional()
|
|
571
|
-
.describe("When true, create another isolated managed worktree instead of reusing the existing worktree
|
|
675
|
+
.describe("When true, create another isolated managed Git worktree instead of reusing the existing physical worktree. Use only when the user explicitly requests separate Git isolation."),
|
|
676
|
+
newWorkspace: z
|
|
677
|
+
.boolean()
|
|
678
|
+
.optional()
|
|
679
|
+
.describe("When true, allocate a fresh logical workspaceId for the same physical checkout or worktree and bind this conversation to it. Use only after the user explicitly requests a new logical workspace."),
|
|
572
680
|
},
|
|
573
681
|
outputSchema: {
|
|
574
682
|
workspaceId: z.string(),
|
|
@@ -597,6 +705,16 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
597
705
|
managed: z.boolean(),
|
|
598
706
|
current: z.boolean(),
|
|
599
707
|
})),
|
|
708
|
+
staleWorkspaces: z.array(z.object({
|
|
709
|
+
workspaceId: z.string(),
|
|
710
|
+
root: z.string(),
|
|
711
|
+
mode: z.enum(["checkout", "worktree"]),
|
|
712
|
+
lastUsedAt: z.string(),
|
|
713
|
+
idleMs: z.number().nonnegative(),
|
|
714
|
+
branch: z.string().optional(),
|
|
715
|
+
targetBranch: z.string().optional(),
|
|
716
|
+
managed: z.boolean(),
|
|
717
|
+
})),
|
|
600
718
|
agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
|
|
601
719
|
availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
|
|
602
720
|
skills: z.array(workspaceSkillOutputSchema).optional(),
|
|
@@ -612,10 +730,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
612
730
|
idempotentHint: false,
|
|
613
731
|
openWorldHint: false,
|
|
614
732
|
},
|
|
615
|
-
}, async ({ path, mode, baseRef, newWorktree }, { _meta, sessionId }) => {
|
|
733
|
+
}, async ({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace }, { _meta, sessionId }) => {
|
|
616
734
|
const startedAt = performance.now();
|
|
617
|
-
const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, mode, baseRef, newWorktree }, {
|
|
735
|
+
const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace }, {
|
|
736
|
+
conversationScopeId: openAiConversationScopeId(_meta),
|
|
737
|
+
protectedWorkspaceIds: processSessions.activeWorkspaceIds(),
|
|
738
|
+
});
|
|
618
739
|
const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
|
|
740
|
+
const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
|
|
619
741
|
if (config.widgets === "changes") {
|
|
620
742
|
await reviewCheckpoints.initializeWorkspace({
|
|
621
743
|
workspaceId: workspace.id,
|
|
@@ -701,6 +823,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
701
823
|
knownWorktrees.length > 0
|
|
702
824
|
? `Known worktrees: ${knownWorktrees.map((worktree) => `${worktree.path} [${worktree.workspaceId}]${worktree.branch ? ` branch=${worktree.branch}` : ""}${worktree.targetBranch ? ` target=${worktree.targetBranch}` : ""}${worktree.current ? " (current)" : ""}`).join(", ")}`
|
|
703
825
|
: undefined,
|
|
826
|
+
staleWorkspaces.length > 0
|
|
827
|
+
? `Idle logical workspaces for this same physical workspace (>2 days): ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. Tell the user these are available to resume or explicitly close; do not clean them up automatically.`
|
|
828
|
+
: undefined,
|
|
704
829
|
instruction,
|
|
705
830
|
].filter(Boolean).join("\n"),
|
|
706
831
|
},
|
|
@@ -712,7 +837,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
712
837
|
success: true,
|
|
713
838
|
durationMs: Math.round(performance.now() - startedAt),
|
|
714
839
|
});
|
|
715
|
-
return attachHookReports({
|
|
840
|
+
return hooks.decorateResult(workspace.id, attachHookReports({
|
|
716
841
|
content: resultContent,
|
|
717
842
|
_meta: {
|
|
718
843
|
tool: "open_workspace",
|
|
@@ -726,6 +851,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
726
851
|
sourceRoot: workspace.sourceRoot,
|
|
727
852
|
worktree: workspace.worktree,
|
|
728
853
|
worktrees: knownWorktrees,
|
|
854
|
+
staleWorkspaces,
|
|
729
855
|
agentsFiles: cardAgentsFiles,
|
|
730
856
|
availableAgentsFiles: cardAvailableAgentsFiles,
|
|
731
857
|
skills: cardSkills,
|
|
@@ -749,6 +875,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
749
875
|
sourceRoot: workspace.sourceRoot,
|
|
750
876
|
worktree: workspace.worktree,
|
|
751
877
|
worktrees: knownWorktrees,
|
|
878
|
+
staleWorkspaces,
|
|
752
879
|
...(includeBootstrapContext
|
|
753
880
|
? {
|
|
754
881
|
agentsFiles: loadedAgentsFiles,
|
|
@@ -761,7 +888,35 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
761
888
|
: {}),
|
|
762
889
|
instruction,
|
|
763
890
|
},
|
|
764
|
-
}, hookReports);
|
|
891
|
+
}, hookReports));
|
|
892
|
+
});
|
|
893
|
+
registerAppTool(server, toolNames.closeWorkspace, {
|
|
894
|
+
title: "Close logical workspace",
|
|
895
|
+
description: "Release one logical ForgeRelay workspaceId after the user explicitly chooses to clean it up. This never deletes checkout files. A worktree handle can be released only when another logical handle still anchors the same physical worktree; use close_worktree to finalize and remove the last managed worktree. Running or unconsumed background processes prevent closure.",
|
|
896
|
+
inputSchema: {
|
|
897
|
+
workspaceId: z.string().describe("Logical workspace ID to release."),
|
|
898
|
+
},
|
|
899
|
+
outputSchema: resultOutputSchema({ workspaceId: z.string() }),
|
|
900
|
+
_meta: {},
|
|
901
|
+
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
902
|
+
}, async ({ workspaceId }) => {
|
|
903
|
+
const workspace = workspaces.getWorkspace(workspaceId);
|
|
904
|
+
return runToolWithHooks(hooks, {
|
|
905
|
+
tool: toolNames.closeWorkspace,
|
|
906
|
+
invocation: workspaceHookInvocation(workspace),
|
|
907
|
+
payload: { workspaceId },
|
|
908
|
+
operation: async () => {
|
|
909
|
+
if (processSessions.activeWorkspaceIds().has(workspaceId)) {
|
|
910
|
+
throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
|
|
911
|
+
}
|
|
912
|
+
workspaces.closeWorkspace(workspaceId);
|
|
913
|
+
const result = `Closed logical workspace ${workspaceId}. Physical project files were not removed.`;
|
|
914
|
+
return {
|
|
915
|
+
content: [textBlock(result)],
|
|
916
|
+
structuredContent: { result, workspaceId },
|
|
917
|
+
};
|
|
918
|
+
},
|
|
919
|
+
});
|
|
765
920
|
});
|
|
766
921
|
registerAppTool(server, toolNames.closeWorktree, {
|
|
767
922
|
title: "Close worktree",
|
|
@@ -795,6 +950,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
795
950
|
payload: { commitMessage },
|
|
796
951
|
afterCwd: (response) => response.structuredContent.sourceRoot,
|
|
797
952
|
operation: async () => {
|
|
953
|
+
const busyWorkspaceIds = workspaces
|
|
954
|
+
.workspaceIdsForPhysicalWorkspace(workspace)
|
|
955
|
+
.filter((id) => processSessions.activeWorkspaceIds().has(id));
|
|
956
|
+
if (busyWorkspaceIds.length > 0) {
|
|
957
|
+
throw new Error(`Cannot close this worktree while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
|
|
958
|
+
}
|
|
798
959
|
const startedAt = performance.now();
|
|
799
960
|
const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
|
|
800
961
|
const result = [
|
|
@@ -840,7 +1001,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
840
1001
|
path: z
|
|
841
1002
|
.string()
|
|
842
1003
|
.describe(config.skillsEnabled
|
|
843
|
-
? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill path from open_workspace skills."
|
|
1004
|
+
? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill path from open_workspace skills, including a ~/... home-relative path."
|
|
844
1005
|
: "File path to read, relative to the workspace root or absolute inside the OS temp directory."),
|
|
845
1006
|
offset: z
|
|
846
1007
|
.number()
|
|
@@ -1554,80 +1715,57 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1554
1715
|
.string()
|
|
1555
1716
|
.optional()
|
|
1556
1717
|
.describe("Optional working directory relative to the workspace root. Defaults to the workspace root."),
|
|
1557
|
-
timeout: z
|
|
1558
|
-
.number()
|
|
1559
|
-
.positive()
|
|
1560
|
-
.max(300)
|
|
1561
|
-
.optional()
|
|
1562
|
-
.describe("Timeout in seconds. Defaults to 30, max 300."),
|
|
1563
1718
|
},
|
|
1564
|
-
outputSchema:
|
|
1719
|
+
outputSchema: processOutputSchema(),
|
|
1565
1720
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
1566
1721
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
1567
|
-
}, async ({ workspaceId,
|
|
1722
|
+
}, async ({ workspaceId, command, workingDirectory }, extra) => {
|
|
1568
1723
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1569
1724
|
return runToolWithHooks(hooks, {
|
|
1570
1725
|
tool: toolNames.shell,
|
|
1571
1726
|
invocation: workspaceHookInvocation(workspace),
|
|
1572
1727
|
payload: {
|
|
1573
|
-
command
|
|
1728
|
+
command,
|
|
1574
1729
|
workingDirectory: workingDirectory ?? ".",
|
|
1575
|
-
timeoutSeconds: input.timeout,
|
|
1576
1730
|
},
|
|
1577
1731
|
isFailure: toolResultIsError,
|
|
1578
1732
|
operation: async () => {
|
|
1579
1733
|
const startedAt = performance.now();
|
|
1580
1734
|
const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
|
|
1581
|
-
const
|
|
1735
|
+
const snapshot = await processSessions.start({
|
|
1736
|
+
workspaceId,
|
|
1737
|
+
command,
|
|
1582
1738
|
cwd,
|
|
1583
|
-
|
|
1739
|
+
workspaceRoot: workspace.root,
|
|
1740
|
+
yieldTimeMs: 300_000,
|
|
1584
1741
|
});
|
|
1585
|
-
if (response.isError) {
|
|
1586
|
-
logFailedToolResponse(config, {
|
|
1587
|
-
tool: toolNames.shell,
|
|
1588
|
-
...workspaceLogContext(workspace, extra.sessionId),
|
|
1589
|
-
workingDirectory: workingDirectory ?? ".",
|
|
1590
|
-
command: input.command,
|
|
1591
|
-
commandLength: input.command.length,
|
|
1592
|
-
}, response.content, startedAt);
|
|
1593
|
-
return response;
|
|
1594
|
-
}
|
|
1595
|
-
const summary = {
|
|
1596
|
-
command: input.command,
|
|
1597
|
-
workingDirectory: workingDirectory ?? ".",
|
|
1598
|
-
...textSummary(response.content),
|
|
1599
|
-
};
|
|
1600
1742
|
logToolCall(config, {
|
|
1601
1743
|
tool: toolNames.shell,
|
|
1602
1744
|
...workspaceLogContext(workspace, extra.sessionId),
|
|
1603
1745
|
workingDirectory: workingDirectory ?? ".",
|
|
1604
|
-
command
|
|
1605
|
-
commandLength:
|
|
1606
|
-
|
|
1746
|
+
command,
|
|
1747
|
+
commandLength: command.length,
|
|
1748
|
+
exitCode: snapshot.exitCode,
|
|
1749
|
+
running: snapshot.running,
|
|
1750
|
+
processSessionId: snapshot.sessionId,
|
|
1751
|
+
success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
|
|
1607
1752
|
durationMs: Math.round(performance.now() - startedAt),
|
|
1608
1753
|
});
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
},
|
|
1620
|
-
structuredContent: {
|
|
1621
|
-
result: contentText(response.content),
|
|
1622
|
-
},
|
|
1623
|
-
};
|
|
1754
|
+
const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
|
|
1755
|
+
command,
|
|
1756
|
+
workingDirectory: workingDirectory ?? ".",
|
|
1757
|
+
running: snapshot.running,
|
|
1758
|
+
exitCode: snapshot.exitCode,
|
|
1759
|
+
wallTimeMs: snapshot.wallTimeMs,
|
|
1760
|
+
});
|
|
1761
|
+
return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
|
|
1762
|
+
? { ...response, isError: true }
|
|
1763
|
+
: response;
|
|
1624
1764
|
},
|
|
1625
1765
|
});
|
|
1626
1766
|
});
|
|
1627
1767
|
}
|
|
1628
|
-
|
|
1629
|
-
registerCodexProcessTools(server, config, workspaces, processSessions, hooks);
|
|
1630
|
-
}
|
|
1768
|
+
registerProcessTools(server, config, workspaces, processSessions, hooks);
|
|
1631
1769
|
if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) {
|
|
1632
1770
|
registerArtifactTools(server, {
|
|
1633
1771
|
config,
|
|
@@ -1665,6 +1803,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1665
1803
|
? getLocalAgentProviderAvailabilitySnapshot()
|
|
1666
1804
|
: [];
|
|
1667
1805
|
const logSessionCloseResults = (reason, results) => {
|
|
1806
|
+
let closedCount = 0;
|
|
1668
1807
|
for (const result of results) {
|
|
1669
1808
|
if (result.error) {
|
|
1670
1809
|
logEvent(config.logging, "warn", "mcp_session_close_failed", {
|
|
@@ -1676,9 +1815,18 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1676
1815
|
});
|
|
1677
1816
|
continue;
|
|
1678
1817
|
}
|
|
1679
|
-
|
|
1818
|
+
closedCount += 1;
|
|
1819
|
+
if (reason === "idle_timeout") {
|
|
1820
|
+
logEvent(config.logging, "debug", "mcp_session_closed", {
|
|
1821
|
+
reason,
|
|
1822
|
+
sessionIdPrefix: sessionIdPrefix(result.sessionId),
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
if (reason === "server_shutdown" && closedCount > 0) {
|
|
1827
|
+
logEvent(config.logging, "debug", "mcp_sessions_closed", {
|
|
1680
1828
|
reason,
|
|
1681
|
-
|
|
1829
|
+
count: closedCount,
|
|
1682
1830
|
});
|
|
1683
1831
|
}
|
|
1684
1832
|
};
|
|
@@ -1760,10 +1908,11 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1760
1908
|
}
|
|
1761
1909
|
logEvent(config.logging, "debug", "mcp_request", {
|
|
1762
1910
|
requestId,
|
|
1763
|
-
|
|
1911
|
+
httpMethod: req.method,
|
|
1764
1912
|
sessionIdPresent: Boolean(sessionId),
|
|
1765
1913
|
sessionIdPrefix: sessionIdPrefix(sessionId),
|
|
1766
1914
|
isInitialize: initializeRequest,
|
|
1915
|
+
...mcpRequestDebugFields(req.body),
|
|
1767
1916
|
});
|
|
1768
1917
|
try {
|
|
1769
1918
|
let transport;
|
|
@@ -1780,7 +1929,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1780
1929
|
onsessioninitialized: (newSessionId) => {
|
|
1781
1930
|
if (transport)
|
|
1782
1931
|
transports.register(newSessionId, transport);
|
|
1783
|
-
logEvent(config.logging, "
|
|
1932
|
+
logEvent(config.logging, "debug", "mcp_session_created", {
|
|
1784
1933
|
requestId,
|
|
1785
1934
|
sessionIdPrefix: sessionIdPrefix(newSessionId),
|
|
1786
1935
|
...requestLogFields(req, config),
|
|
@@ -1790,7 +1939,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1790
1939
|
transport.onclose = () => {
|
|
1791
1940
|
const closedSessionId = transport?.sessionId;
|
|
1792
1941
|
if (closedSessionId && transports.remove(closedSessionId)) {
|
|
1793
|
-
logEvent(config.logging, "
|
|
1942
|
+
logEvent(config.logging, "debug", "mcp_session_closed", {
|
|
1794
1943
|
reason: "transport_close",
|
|
1795
1944
|
sessionIdPrefix: sessionIdPrefix(closedSessionId),
|
|
1796
1945
|
});
|