@akira-tl/forgerelay 0.2.4 → 0.2.6
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/dist/artifact-tools.js +2 -3
- package/dist/logger.js +77 -10
- package/dist/mcp/server-instructions.js +4 -4
- package/dist/mcp-app-template.js +45 -0
- package/dist/mcp-sessions.js +24 -22
- package/dist/process-sessions.js +135 -108
- package/dist/roots.js +1 -1
- package/dist/server.js +165 -88
- package/docs/chatgpt-coding-workflow.md +3 -2
- package/docs/configuration.md +11 -6
- package/docs/debugging.md +35 -7
- package/docs/gotchas.md +22 -1
- package/docs/roadmap.md +26 -0
- package/docs/security.md +3 -2
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +79 -3
- 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";
|
|
@@ -20,26 +20,26 @@ import { loadConfig } from "./config.js";
|
|
|
20
20
|
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
|
-
import { logEvent, requestIp, requestPath, commandPreview,
|
|
23
|
+
import { logEvent, requestIp, requestPath, commandPreview, transportSessionIdPrefix, workspaceLogLabel, } from "./logger.js";
|
|
24
24
|
import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
|
|
25
25
|
import { SingleUserOAuthProvider } from "./oauth-provider.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { McpTransportRegistry, } from "./mcp-sessions.js";
|
|
27
|
+
import { ProcessManager, resolveProcessId, } 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";
|
|
33
34
|
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
|
|
34
35
|
import { summarizeLocalAgentProfile } from "./local-agent-profiles.js";
|
|
35
36
|
import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js";
|
|
36
|
-
// MCP clients can reconnect without closing the previous
|
|
37
|
-
// session retention so abandoned
|
|
38
|
-
|
|
37
|
+
// Legacy MCP Streamable HTTP clients can reconnect without closing the previous
|
|
38
|
+
// transport. Bound stale transport-session retention so abandoned transports do
|
|
39
|
+
// not accumulate for the life of the process.
|
|
40
|
+
const MCP_TRANSPORT_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
|
|
39
41
|
const FORGERELAY_VERSION = readForgeRelayVersion();
|
|
40
|
-
const
|
|
41
|
-
const WORKSPACE_APP_URI = "ui://forgerelay/workspace-app.html";
|
|
42
|
-
const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html";
|
|
42
|
+
const MCP_TRANSPORT_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
|
|
43
43
|
const WRITE_TOOL_ANNOTATIONS = {
|
|
44
44
|
readOnlyHint: false,
|
|
45
45
|
destructiveHint: true,
|
|
@@ -71,20 +71,21 @@ function shouldAttachWidget(mode, kind) {
|
|
|
71
71
|
function toolWidgetDescriptorMeta(config, kind) {
|
|
72
72
|
if (!shouldAttachWidget(config.widgets, kind))
|
|
73
73
|
return { _meta: {} };
|
|
74
|
+
const resourceUri = currentWorkspaceAppIdentity().uri;
|
|
74
75
|
return {
|
|
75
76
|
_meta: {
|
|
76
77
|
ui: {
|
|
77
|
-
resourceUri
|
|
78
|
-
visibility: ["model"],
|
|
78
|
+
resourceUri,
|
|
79
|
+
visibility: ["model", "app"],
|
|
79
80
|
},
|
|
81
|
+
"openai/outputTemplate": resourceUri,
|
|
80
82
|
},
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
|
-
function workspaceLogContext(workspace,
|
|
85
|
+
function workspaceLogContext(workspace, _transportSessionId) {
|
|
84
86
|
return {
|
|
85
87
|
workspaceId: workspace.id,
|
|
86
88
|
workspace: workspaceLogLabel(workspace.root, workspace.id),
|
|
87
|
-
session: sessionIdPrefix(sessionId),
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
function formatVisibleAgent(agent) {
|
|
@@ -161,6 +162,23 @@ function requestLogFields(req, config) {
|
|
|
161
162
|
contentLength: req.header("content-length"),
|
|
162
163
|
};
|
|
163
164
|
}
|
|
165
|
+
function mcpRequestDebugFields(body) {
|
|
166
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
167
|
+
return {};
|
|
168
|
+
const request = body;
|
|
169
|
+
const rpcMethod = typeof request.method === "string" ? request.method : undefined;
|
|
170
|
+
const params = request.params && typeof request.params === "object" && !Array.isArray(request.params)
|
|
171
|
+
? request.params
|
|
172
|
+
: undefined;
|
|
173
|
+
let rpcTarget;
|
|
174
|
+
if (rpcMethod === "resources/read" && typeof params?.uri === "string") {
|
|
175
|
+
rpcTarget = params.uri;
|
|
176
|
+
}
|
|
177
|
+
else if (rpcMethod === "tools/call" && typeof params?.name === "string") {
|
|
178
|
+
rpcTarget = params.name;
|
|
179
|
+
}
|
|
180
|
+
return { rpcMethod, rpcTarget };
|
|
181
|
+
}
|
|
164
182
|
function logToolCall(config, fields) {
|
|
165
183
|
if (!config.logging.toolCalls)
|
|
166
184
|
return;
|
|
@@ -247,16 +265,20 @@ function assetBaseUrl(config) {
|
|
|
247
265
|
function uiManifestUrl() {
|
|
248
266
|
return new URL("../dist/ui/.vite/manifest.json", import.meta.url);
|
|
249
267
|
}
|
|
250
|
-
function
|
|
251
|
-
return
|
|
268
|
+
function uiBuildDirectoryUrl() {
|
|
269
|
+
return new URL("../dist/ui/", import.meta.url);
|
|
270
|
+
}
|
|
271
|
+
let cachedWorkspaceAppIdentity;
|
|
272
|
+
function currentWorkspaceAppIdentity() {
|
|
273
|
+
cachedWorkspaceAppIdentity ??= resolveWorkspaceAppIdentity({
|
|
274
|
+
manifestUrl: uiManifestUrl(),
|
|
275
|
+
buildDirectoryUrl: uiBuildDirectoryUrl(),
|
|
276
|
+
fallbackRevision: FORGERELAY_VERSION,
|
|
277
|
+
});
|
|
278
|
+
return cachedWorkspaceAppIdentity;
|
|
252
279
|
}
|
|
253
280
|
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;
|
|
281
|
+
return readWorkspaceAppManifestEntry(uiManifestUrl());
|
|
260
282
|
}
|
|
261
283
|
function assetUrl(baseUrl, assetPath) {
|
|
262
284
|
return `${baseUrl}/${assetPath.replace(/^\/+/, "")}`;
|
|
@@ -306,9 +328,54 @@ async function assertWorkspaceAppAssets() {
|
|
|
306
328
|
await access(candidate);
|
|
307
329
|
}
|
|
308
330
|
}
|
|
331
|
+
function workspaceAppCompatibilityKind(requestedUri, currentUri) {
|
|
332
|
+
if (requestedUri === currentUri)
|
|
333
|
+
return "current";
|
|
334
|
+
if (requestedUri === WORKSPACE_APP_LEGACY_URI)
|
|
335
|
+
return "legacy";
|
|
336
|
+
return "historical";
|
|
337
|
+
}
|
|
338
|
+
async function readWorkspaceAppResource(config, requestedUri, transportSessionId) {
|
|
339
|
+
const currentUri = currentWorkspaceAppIdentity().uri;
|
|
340
|
+
const compatibility = workspaceAppCompatibilityKind(requestedUri, currentUri);
|
|
341
|
+
try {
|
|
342
|
+
await assertWorkspaceAppAssets();
|
|
343
|
+
const result = {
|
|
344
|
+
contents: [
|
|
345
|
+
{
|
|
346
|
+
uri: requestedUri,
|
|
347
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
348
|
+
text: workspaceAppHtml(config),
|
|
349
|
+
_meta: {
|
|
350
|
+
ui: {
|
|
351
|
+
csp: appCsp(config),
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
logEvent(config.logging, "debug", "mcp_app_template_read", {
|
|
358
|
+
requestedUri,
|
|
359
|
+
currentUri,
|
|
360
|
+
compatibility,
|
|
361
|
+
transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
|
|
362
|
+
});
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
logEvent(config.logging, "warn", "mcp_app_template_read_failed", {
|
|
367
|
+
requestedUri,
|
|
368
|
+
currentUri,
|
|
369
|
+
compatibility,
|
|
370
|
+
error: error instanceof Error ? error.message : String(error),
|
|
371
|
+
transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
|
|
372
|
+
});
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
309
376
|
function processResult(snapshot) {
|
|
310
377
|
const status = snapshot.running
|
|
311
|
-
? `Process running with
|
|
378
|
+
? `Process running with process ID ${snapshot.processId}.`
|
|
312
379
|
: snapshot.signal
|
|
313
380
|
? `Process exited after signal ${snapshot.signal}.`
|
|
314
381
|
: `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
|
|
@@ -316,8 +383,8 @@ function processResult(snapshot) {
|
|
|
316
383
|
}
|
|
317
384
|
function completedProcessResult(snapshot) {
|
|
318
385
|
const status = snapshot.signal
|
|
319
|
-
? `Background process ${snapshot.
|
|
320
|
-
: `Background process ${snapshot.
|
|
386
|
+
? `Background process ${snapshot.processId} exited after signal ${snapshot.signal}.`
|
|
387
|
+
: `Background process ${snapshot.processId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
|
|
321
388
|
const command = `Command: ${snapshot.command}`;
|
|
322
389
|
const output = snapshot.output ? `\n${snapshot.output.replace(/\n$/, "")}` : "";
|
|
323
390
|
return `${status}\n${command}${output}`;
|
|
@@ -339,10 +406,14 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
|
|
|
339
406
|
if (!Array.isArray(content))
|
|
340
407
|
return result;
|
|
341
408
|
const structured = result.structuredContent;
|
|
342
|
-
const
|
|
343
|
-
? structured.
|
|
409
|
+
const currentProcessId = structured?.running === true
|
|
410
|
+
? typeof structured.processId === "number"
|
|
411
|
+
? structured.processId
|
|
412
|
+
: typeof structured.sessionId === "number"
|
|
413
|
+
? structured.sessionId
|
|
414
|
+
: undefined
|
|
344
415
|
: undefined;
|
|
345
|
-
const completed = processSessions.takeCompleted(workspaceId, undefined,
|
|
416
|
+
const completed = processSessions.takeCompleted(workspaceId, undefined, currentProcessId);
|
|
346
417
|
if (completed.length === 0)
|
|
347
418
|
return result;
|
|
348
419
|
return {
|
|
@@ -355,7 +426,8 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
|
|
|
355
426
|
}
|
|
356
427
|
function processOutputSchema() {
|
|
357
428
|
return resultOutputSchema({
|
|
358
|
-
|
|
429
|
+
processId: z.number().int().positive().optional().describe("Canonical process handle for write_stdin."),
|
|
430
|
+
sessionId: z.number().int().positive().optional().describe("Deprecated alias of processId for compatibility."),
|
|
359
431
|
running: z.boolean(),
|
|
360
432
|
exitCode: z.number().int().optional(),
|
|
361
433
|
signal: z.string().optional(),
|
|
@@ -386,6 +458,7 @@ function processToolResponse(tool, workspaceId, snapshot, summary) {
|
|
|
386
458
|
},
|
|
387
459
|
structuredContent: {
|
|
388
460
|
result,
|
|
461
|
+
processId: snapshot.processId,
|
|
389
462
|
sessionId: snapshot.sessionId,
|
|
390
463
|
running: snapshot.running,
|
|
391
464
|
exitCode: snapshot.exitCode,
|
|
@@ -410,7 +483,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
410
483
|
if (config.toolMode === "codex") {
|
|
411
484
|
registerAppTool(server, "exec_command", {
|
|
412
485
|
title: "Execute command",
|
|
413
|
-
description: `Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a
|
|
486
|
+
description: `Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a processId 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.`,
|
|
414
487
|
inputSchema: {
|
|
415
488
|
workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
|
|
416
489
|
cmd: z.string().min(1).describe("Shell command to execute."),
|
|
@@ -430,7 +503,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
430
503
|
.min(0)
|
|
431
504
|
.max(30_000)
|
|
432
505
|
.optional()
|
|
433
|
-
.describe("Milliseconds to wait before returning a running
|
|
506
|
+
.describe("Milliseconds to wait before returning a running process. Defaults to 10000."),
|
|
434
507
|
maxOutputTokens: z
|
|
435
508
|
.number()
|
|
436
509
|
.int()
|
|
@@ -471,7 +544,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
471
544
|
commandLength: cmd.length,
|
|
472
545
|
exitCode: snapshot.exitCode,
|
|
473
546
|
running: snapshot.running,
|
|
474
|
-
|
|
547
|
+
processId: snapshot.processId,
|
|
475
548
|
success: snapshot.running || snapshot.exitCode === 0,
|
|
476
549
|
durationMs: Math.round(performance.now() - startedAt),
|
|
477
550
|
});
|
|
@@ -491,7 +564,8 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
491
564
|
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.",
|
|
492
565
|
inputSchema: {
|
|
493
566
|
workspaceId: z.string().describe("Workspace identifier used to start the process."),
|
|
494
|
-
|
|
567
|
+
processId: z.number().int().positive().optional().describe("Canonical process identifier returned by bash or exec_command."),
|
|
568
|
+
sessionId: z.number().int().positive().optional().describe("Deprecated alias for processId. Retained for compatibility."),
|
|
495
569
|
chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."),
|
|
496
570
|
columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."),
|
|
497
571
|
rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."),
|
|
@@ -513,13 +587,14 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
513
587
|
outputSchema: processOutputSchema(),
|
|
514
588
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
515
589
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
516
|
-
}, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
|
|
590
|
+
}, async ({ workspaceId, processId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
|
|
517
591
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
592
|
+
const resolvedProcessId = resolveProcessId(processId, sessionId);
|
|
518
593
|
return runToolWithHooks(hooks, {
|
|
519
594
|
tool: "write_stdin",
|
|
520
595
|
invocation: workspaceHookInvocation(workspace),
|
|
521
596
|
payload: {
|
|
522
|
-
|
|
597
|
+
processId: resolvedProcessId,
|
|
523
598
|
charactersWritten: chars?.length ?? 0,
|
|
524
599
|
columns,
|
|
525
600
|
rows,
|
|
@@ -528,7 +603,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
528
603
|
const startedAt = performance.now();
|
|
529
604
|
const snapshot = await processSessions.write({
|
|
530
605
|
workspaceId,
|
|
531
|
-
|
|
606
|
+
processId: resolvedProcessId,
|
|
532
607
|
chars,
|
|
533
608
|
columns,
|
|
534
609
|
rows,
|
|
@@ -540,12 +615,12 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
540
615
|
...workspaceLogContext(workspace, extra.sessionId),
|
|
541
616
|
exitCode: snapshot.exitCode,
|
|
542
617
|
running: snapshot.running,
|
|
543
|
-
|
|
618
|
+
processId: snapshot.processId,
|
|
544
619
|
success: snapshot.running || snapshot.exitCode === 0,
|
|
545
620
|
durationMs: Math.round(performance.now() - startedAt),
|
|
546
621
|
});
|
|
547
622
|
return processToolResponse("write_stdin", workspaceId, snapshot, {
|
|
548
|
-
|
|
623
|
+
processId: resolvedProcessId,
|
|
549
624
|
charactersWritten: chars?.length ?? 0,
|
|
550
625
|
running: snapshot.running,
|
|
551
626
|
exitCode: snapshot.exitCode,
|
|
@@ -568,30 +643,21 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
568
643
|
artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
|
|
569
644
|
}),
|
|
570
645
|
});
|
|
571
|
-
|
|
646
|
+
const currentWorkspaceAppUri = currentWorkspaceAppIdentity().uri;
|
|
647
|
+
const workspaceAppResourceMetadata = {
|
|
572
648
|
description: "Interactive card for viewing ForgeRelay file diffs.",
|
|
573
649
|
_meta: {
|
|
574
650
|
ui: {
|
|
575
651
|
csp: appCsp(config),
|
|
576
652
|
},
|
|
577
653
|
},
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
text: workspaceAppHtml(config),
|
|
586
|
-
_meta: {
|
|
587
|
-
ui: {
|
|
588
|
-
csp: appCsp(config),
|
|
589
|
-
},
|
|
590
|
-
},
|
|
591
|
-
},
|
|
592
|
-
],
|
|
593
|
-
};
|
|
594
|
-
});
|
|
654
|
+
};
|
|
655
|
+
registerAppResource(server, "ForgeRelay Diff Card", currentWorkspaceAppUri, workspaceAppResourceMetadata, async (uri, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
|
|
656
|
+
registerAppResource(server, "ForgeRelay Diff Card legacy", WORKSPACE_APP_LEGACY_URI, workspaceAppResourceMetadata, async (uri, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
|
|
657
|
+
server.registerResource("ForgeRelay Diff Card compatibility", new ResourceTemplate(WORKSPACE_APP_URI_TEMPLATE, { list: undefined }), {
|
|
658
|
+
...workspaceAppResourceMetadata,
|
|
659
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
660
|
+
}, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
|
|
595
661
|
registerAppTool(server, "open_workspace", {
|
|
596
662
|
title: "Open workspace",
|
|
597
663
|
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.",
|
|
@@ -944,7 +1010,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
944
1010
|
path: z
|
|
945
1011
|
.string()
|
|
946
1012
|
.describe(config.skillsEnabled
|
|
947
|
-
? "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."
|
|
1013
|
+
? "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."
|
|
948
1014
|
: "File path to read, relative to the workspace root or absolute inside the OS temp directory."),
|
|
949
1015
|
offset: z
|
|
950
1016
|
.number()
|
|
@@ -1690,7 +1756,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1690
1756
|
commandLength: command.length,
|
|
1691
1757
|
exitCode: snapshot.exitCode,
|
|
1692
1758
|
running: snapshot.running,
|
|
1693
|
-
|
|
1759
|
+
processId: snapshot.processId,
|
|
1694
1760
|
success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
|
|
1695
1761
|
durationMs: Math.round(performance.now() - startedAt),
|
|
1696
1762
|
});
|
|
@@ -1729,7 +1795,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1729
1795
|
host: config.host,
|
|
1730
1796
|
...(allowedHosts ? { allowedHosts } : {}),
|
|
1731
1797
|
});
|
|
1732
|
-
const transports = new
|
|
1798
|
+
const transports = new McpTransportRegistry();
|
|
1733
1799
|
const mcpUrl = new URL("/mcp", config.publicBaseUrl);
|
|
1734
1800
|
const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
|
|
1735
1801
|
const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
|
|
@@ -1741,34 +1807,44 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1741
1807
|
const workspaceStore = createWorkspaceStore(config.stateDir);
|
|
1742
1808
|
const workspaces = new WorkspaceRegistry(config, workspaceStore);
|
|
1743
1809
|
const reviewCheckpoints = createReviewCheckpointManager();
|
|
1744
|
-
const processSessions = new
|
|
1810
|
+
const processSessions = new ProcessManager();
|
|
1745
1811
|
const localAgentProviders = config.subagents
|
|
1746
1812
|
? getLocalAgentProviderAvailabilitySnapshot()
|
|
1747
1813
|
: [];
|
|
1748
|
-
const
|
|
1814
|
+
const logTransportCloseResults = (reason, results) => {
|
|
1815
|
+
let closedCount = 0;
|
|
1749
1816
|
for (const result of results) {
|
|
1750
1817
|
if (result.error) {
|
|
1751
|
-
logEvent(config.logging, "warn", "
|
|
1818
|
+
logEvent(config.logging, "warn", "mcp_transport_session_close_failed", {
|
|
1752
1819
|
reason,
|
|
1753
|
-
|
|
1820
|
+
transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
|
|
1754
1821
|
error: result.error instanceof Error
|
|
1755
1822
|
? result.error.message
|
|
1756
1823
|
: String(result.error),
|
|
1757
1824
|
});
|
|
1758
1825
|
continue;
|
|
1759
1826
|
}
|
|
1760
|
-
|
|
1827
|
+
closedCount += 1;
|
|
1828
|
+
if (reason === "idle_timeout") {
|
|
1829
|
+
logEvent(config.logging, "debug", "mcp_transport_session_closed", {
|
|
1830
|
+
reason,
|
|
1831
|
+
transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
if (reason === "server_shutdown" && closedCount > 0) {
|
|
1836
|
+
logEvent(config.logging, "debug", "mcp_transport_sessions_closed", {
|
|
1761
1837
|
reason,
|
|
1762
|
-
|
|
1838
|
+
count: closedCount,
|
|
1763
1839
|
});
|
|
1764
1840
|
}
|
|
1765
1841
|
};
|
|
1766
|
-
const
|
|
1842
|
+
const transportCleanupTimer = setInterval(() => {
|
|
1767
1843
|
void transports
|
|
1768
|
-
.closeIdle(
|
|
1769
|
-
.then((results) =>
|
|
1770
|
-
},
|
|
1771
|
-
|
|
1844
|
+
.closeIdle(MCP_TRANSPORT_IDLE_TIMEOUT_MS)
|
|
1845
|
+
.then((results) => logTransportCloseResults("idle_timeout", results));
|
|
1846
|
+
}, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
|
|
1847
|
+
transportCleanupTimer.unref();
|
|
1772
1848
|
if (config.logging.trustProxy) {
|
|
1773
1849
|
app.set("trust proxy", true);
|
|
1774
1850
|
}
|
|
@@ -1816,7 +1892,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1816
1892
|
});
|
|
1817
1893
|
app.all("/mcp", async (req, res) => {
|
|
1818
1894
|
const requestId = res.locals.requestId;
|
|
1819
|
-
const
|
|
1895
|
+
const transportSessionId = req.header("mcp-session-id");
|
|
1820
1896
|
const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);
|
|
1821
1897
|
await new Promise((resolve, reject) => {
|
|
1822
1898
|
bearerAuth(req, res, (error) => {
|
|
@@ -1841,39 +1917,40 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1841
1917
|
}
|
|
1842
1918
|
logEvent(config.logging, "debug", "mcp_request", {
|
|
1843
1919
|
requestId,
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1920
|
+
httpMethod: req.method,
|
|
1921
|
+
transportSessionIdPresent: Boolean(transportSessionId),
|
|
1922
|
+
transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
|
|
1847
1923
|
isInitialize: initializeRequest,
|
|
1924
|
+
...mcpRequestDebugFields(req.body),
|
|
1848
1925
|
});
|
|
1849
1926
|
try {
|
|
1850
1927
|
let transport;
|
|
1851
|
-
if (
|
|
1852
|
-
transport = transports.get(
|
|
1928
|
+
if (transportSessionId) {
|
|
1929
|
+
transport = transports.get(transportSessionId);
|
|
1853
1930
|
if (!transport) {
|
|
1854
|
-
sendJsonRpcError(res, 404, -32000, "Unknown MCP session");
|
|
1931
|
+
sendJsonRpcError(res, 404, -32000, "Unknown MCP transport session");
|
|
1855
1932
|
return;
|
|
1856
1933
|
}
|
|
1857
1934
|
}
|
|
1858
1935
|
else if (initializeRequest) {
|
|
1859
1936
|
transport = new StreamableHTTPServerTransport({
|
|
1860
1937
|
sessionIdGenerator: () => randomUUID(),
|
|
1861
|
-
onsessioninitialized: (
|
|
1938
|
+
onsessioninitialized: (newTransportSessionId) => {
|
|
1862
1939
|
if (transport)
|
|
1863
|
-
transports.register(
|
|
1864
|
-
logEvent(config.logging, "
|
|
1940
|
+
transports.register(newTransportSessionId, transport);
|
|
1941
|
+
logEvent(config.logging, "debug", "mcp_transport_session_created", {
|
|
1865
1942
|
requestId,
|
|
1866
|
-
|
|
1943
|
+
transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),
|
|
1867
1944
|
...requestLogFields(req, config),
|
|
1868
1945
|
});
|
|
1869
1946
|
},
|
|
1870
1947
|
});
|
|
1871
1948
|
transport.onclose = () => {
|
|
1872
|
-
const
|
|
1873
|
-
if (
|
|
1874
|
-
logEvent(config.logging, "
|
|
1949
|
+
const closedTransportSessionId = transport?.sessionId;
|
|
1950
|
+
if (closedTransportSessionId && transports.remove(closedTransportSessionId)) {
|
|
1951
|
+
logEvent(config.logging, "debug", "mcp_transport_session_closed", {
|
|
1875
1952
|
reason: "transport_close",
|
|
1876
|
-
|
|
1953
|
+
transportSessionIdPrefix: transportSessionIdPrefix(closedTransportSessionId),
|
|
1877
1954
|
});
|
|
1878
1955
|
}
|
|
1879
1956
|
};
|
|
@@ -1881,7 +1958,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1881
1958
|
await server.connect(transport);
|
|
1882
1959
|
}
|
|
1883
1960
|
else {
|
|
1884
|
-
sendJsonRpcError(res, 400, -32000, "No valid MCP session");
|
|
1961
|
+
sendJsonRpcError(res, 400, -32000, "No valid MCP transport session");
|
|
1885
1962
|
return;
|
|
1886
1963
|
}
|
|
1887
1964
|
await transport.handleRequest(req, res, req.body);
|
|
@@ -1903,9 +1980,9 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
1903
1980
|
localAgentProviders,
|
|
1904
1981
|
close: () => {
|
|
1905
1982
|
closePromise ??= (async () => {
|
|
1906
|
-
clearInterval(
|
|
1983
|
+
clearInterval(transportCleanupTimer);
|
|
1907
1984
|
const results = await transports.closeAll();
|
|
1908
|
-
|
|
1985
|
+
logTransportCloseResults("server_shutdown", results);
|
|
1909
1986
|
processSessions.shutdown();
|
|
1910
1987
|
oauthProvider.close();
|
|
1911
1988
|
workspaceStore.close?.();
|
|
@@ -168,10 +168,11 @@ The exact lifecycle tools available depend on the active server configuration.
|
|
|
168
168
|
In minimal mode, normal shell inspection commands such as `rg`, `find`, and `ls`
|
|
169
169
|
can be used rather than dedicated MCP search tools. `bash` waits in the foreground
|
|
170
170
|
for at most 300 seconds. If the command is still running, ForgeRelay returns a
|
|
171
|
-
|
|
171
|
+
canonical `processId` without killing it. The Agent can use `write_stdin` to poll,
|
|
172
172
|
wait again, interact, or explicitly send Ctrl-C, or continue other work; once the
|
|
173
173
|
command finishes, its completion is attached to a later tool result using the
|
|
174
|
-
same workspace ID.
|
|
174
|
+
same workspace ID. The former process `sessionId` remains a deprecated alias in
|
|
175
|
+
0.2.x for compatibility with existing clients.
|
|
175
176
|
|
|
176
177
|
`FORGERELAY_TOOL_MODE=full` adds dedicated search/directory tools.
|
|
177
178
|
|
package/docs/configuration.md
CHANGED
|
@@ -132,16 +132,17 @@ receives a different ID for the same physical checkout/worktree. Pass
|
|
|
132
132
|
`workspaceId` to `open_workspace` to explicitly resume an existing handle in the
|
|
133
133
|
current conversation. `newWorkspace: true` allocates a new logical handle without
|
|
134
134
|
creating another checkout or Git worktree and should be used only on explicit user
|
|
135
|
-
request.
|
|
135
|
+
request. Logical workspaces idle for more than two days are returned in `staleWorkspaces` so
|
|
136
136
|
the user can choose whether to resume or release them. `close_workspace` removes a
|
|
137
137
|
logical handle without deleting checkout files; it refuses to remove the last
|
|
138
138
|
handle anchoring a physical worktree.
|
|
139
139
|
|
|
140
140
|
`bash` has no execution-timeout input. It waits in the foreground for at most 300
|
|
141
141
|
seconds; if the process is still alive, the result contains `running: true` and a
|
|
142
|
-
`
|
|
143
|
-
300 seconds per call.
|
|
144
|
-
|
|
142
|
+
canonical `processId`. `write_stdin` can poll or interact with that process for up
|
|
143
|
+
to another 300 seconds per call. The former `sessionId` field remains a deprecated
|
|
144
|
+
alias during the 0.2.x compatibility window. ForgeRelay does not kill a process
|
|
145
|
+
merely because a wait window expires. Completed background processes are delivered once with a later
|
|
145
146
|
tool result for the same logical workspace ID.
|
|
146
147
|
|
|
147
148
|
## Widgets
|
|
@@ -336,8 +337,12 @@ forgerelay agents show <id>
|
|
|
336
337
|
| `FORGERELAY_TRUST_PROXY` | `0` |
|
|
337
338
|
|
|
338
339
|
`pretty` is the human-facing local console format. It uses terminal-aware color,
|
|
339
|
-
short timestamps, workspace
|
|
340
|
-
keeping HTTP request records off by default.
|
|
340
|
+
short timestamps, workspace-first context, and compact operation results while
|
|
341
|
+
keeping HTTP request records off by default. Project names receive stable
|
|
342
|
+
per-project colors and logical `ws_...` identifiers remain visible; transient MCP
|
|
343
|
+
transport session IDs and normal transport lifecycle events are shown only at
|
|
344
|
+
`debug` level, where they are labeled as `transport` rather than workspace/process
|
|
345
|
+
identity. Shell command previews are enabled
|
|
341
346
|
in this mode and truncated to 120 characters; set
|
|
342
347
|
`FORGERELAY_LOG_SHELL_COMMANDS=0` when command arguments may contain secrets.
|
|
343
348
|
|
package/docs/debugging.md
CHANGED
|
@@ -59,17 +59,45 @@ The acceptance checks:
|
|
|
59
59
|
3. unauthenticated `/mcp` rejection;
|
|
60
60
|
4. dynamic OAuth client registration, PKCE Owner-password approval, and access-token exchange;
|
|
61
61
|
5. MCP `initialize`, including package/server version consistency and the shell mutation safety contract;
|
|
62
|
-
6. `tools/list` for the full debug tool surface, including `close_workspace`, `write_stdin`, the non-blanket `bash` mutation policy, no kill-timeout input, the 300-second foreground-wait contract,
|
|
63
|
-
7.
|
|
64
|
-
8.
|
|
65
|
-
9.
|
|
66
|
-
10.
|
|
67
|
-
11.
|
|
68
|
-
12.
|
|
62
|
+
6. `tools/list` for the full debug tool surface, including `close_workspace`, `write_stdin`, canonical `processId` plus the deprecated `sessionId` compatibility alias, the non-blanket `bash` mutation policy, no kill-timeout input, the 300-second foreground-wait contract, workspace resume/stale-workspace schema, and MCP App tool metadata;
|
|
63
|
+
7. the full MCP App template chain: `resources/list`, `resources/templates/list`, current content-hashed `resources/read`, legacy/historical template compatibility reads, `text/html;profile=mcp-app`, CSP resource domains, and an HTTP fetch of the JavaScript asset referenced by the template;
|
|
64
|
+
8. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash` through `ProcessManager`, and a deliberate failed `edit`;
|
|
65
|
+
9. OS temp-directory `write` → `read` → `edit` → `rename` → `delete` over the same real MCP transport session, plus rejection of an arbitrary path outside the workspace/temp roots;
|
|
66
|
+
10. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
|
|
67
|
+
11. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
|
|
68
|
+
12. deterministic local subagent error path,不联系任何模型 provider;
|
|
69
|
+
13. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
|
|
69
70
|
|
|
70
71
|
`curl` must be available on `PATH` for this acceptance command. Node and Git are
|
|
71
72
|
already normal ForgeRelay development prerequisites.
|
|
72
73
|
|
|
74
|
+
## Debug ChatGPT template loading
|
|
75
|
+
|
|
76
|
+
The normal debug runtime keeps widgets off so source-only server iteration does
|
|
77
|
+
not accidentally serve stale UI assets. To debug the MCP App path, build first
|
|
78
|
+
and enable widgets explicitly:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm run build
|
|
82
|
+
FORGERELAY_DEBUG_WIDGETS=full \
|
|
83
|
+
FORGERELAY_LOG_LEVEL=debug \
|
|
84
|
+
FORGERELAY_LOG_REQUESTS=1 \
|
|
85
|
+
FORGERELAY_LOG_ASSETS=1 \
|
|
86
|
+
npm run dev
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
At `debug` level, MCP requests include the JSON-RPC method and a safe target for
|
|
90
|
+
`resources/read` and `tools/call`, while transport session IDs remain out of
|
|
91
|
+
normal `info` tool logs. Successful template callbacks also emit `app template`
|
|
92
|
+
entries identifying `current`, `legacy`, or `historical` compatibility reads. A
|
|
93
|
+
successful ChatGPT template load should produce a sequence containing
|
|
94
|
+
`resources/list`, `resources/read ui://...`, an `app template ... -> ok` entry,
|
|
95
|
+
and then an HTTP `GET /mcp-app-assets/...` request. If `resources/read` never
|
|
96
|
+
arrives, inspect the client/developer-mode connection. If it arrives and fails,
|
|
97
|
+
inspect the MCP resource registration/build artifacts. If it succeeds but the
|
|
98
|
+
asset request fails, inspect the public base URL, CSP, asset route, and browser
|
|
99
|
+
console.
|
|
100
|
+
|
|
73
101
|
## Debug configuration
|
|
74
102
|
|
|
75
103
|
The checked-in debug configuration is:
|
package/docs/gotchas.md
CHANGED
|
@@ -234,7 +234,28 @@ FORGERELAY_WIDGETS=full
|
|
|
234
234
|
```
|
|
235
235
|
|
|
236
236
|
Use `FORGERELAY_WIDGETS=changes` for aggregate `show_changes`, or `off` to
|
|
237
|
-
disable UI. Plain MCP clients may ignore
|
|
237
|
+
disable UI. Plain MCP clients may ignore MCP App widget metadata.
|
|
238
|
+
|
|
239
|
+
If ChatGPT shows `Failed to fetch template`, first verify the server-side template
|
|
240
|
+
chain with:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
npm run build
|
|
244
|
+
npm run debug:accept
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The acceptance runner enables full widgets and checks that the tool advertises a
|
|
248
|
+
content-hashed `ui://forgerelay/workspace-app-<hash>.html` resource, that
|
|
249
|
+
`resources/read` returns `text/html;profile=mcp-app`, and that the referenced
|
|
250
|
+
JavaScript asset is reachable. ForgeRelay also keeps the legacy
|
|
251
|
+
`ui://forgerelay/workspace-app.html` pointer and historical
|
|
252
|
+
`workspace-app-*.html` pointers readable so an older ChatGPT metadata snapshot
|
|
253
|
+
can still fetch the current template while the connection is being refreshed.
|
|
254
|
+
For a live ChatGPT trace, run the debug server with
|
|
255
|
+
`FORGERELAY_DEBUG_WIDGETS=full`, `FORGERELAY_LOG_LEVEL=debug`,
|
|
256
|
+
`FORGERELAY_LOG_REQUESTS=1`, and `FORGERELAY_LOG_ASSETS=1`; then distinguish a
|
|
257
|
+
missing `resources/read` request from a template callback failure or a failed
|
|
258
|
+
`/mcp-app-assets/` fetch.
|
|
238
259
|
|
|
239
260
|
## Data retention
|
|
240
261
|
|