@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/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, sessionIdPrefix, workspaceLogLabel, } from "./logger.js";
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 { McpSessionRegistry, } from "./mcp-sessions.js";
27
- import { ProcessSessionManager, } from "./process-sessions.js";
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 transport. Bound stale
37
- // session retention so abandoned MCP servers do not accumulate for the life of the process.
38
- const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
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 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";
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: WORKSPACE_APP_URI,
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, sessionId) {
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 readWorkspaceAppManifest() {
251
- return JSON.parse(readFileSync(uiManifestUrl(), "utf8"));
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
- const manifest = readWorkspaceAppManifest();
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 session ID ${snapshot.sessionId}.`
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.sessionId} exited after signal ${snapshot.signal}.`
320
- : `Background process ${snapshot.sessionId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
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 currentSessionId = structured?.running === true && typeof structured.sessionId === "number"
343
- ? structured.sessionId
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, currentSessionId);
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
- sessionId: z.number().optional(),
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 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.`,
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 session. Defaults to 10000."),
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
- processSessionId: snapshot.sessionId,
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
- sessionId: z.number().describe("Process session identifier returned by exec_command."),
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
- sessionId,
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
- sessionId,
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
- processSessionId: snapshot.sessionId,
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
- sessionId,
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
- registerAppResource(server, "ForgeRelay Diff Card", WORKSPACE_APP_URI, {
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
- }, async () => {
579
- await assertWorkspaceAppAssets();
580
- return {
581
- contents: [
582
- {
583
- uri: WORKSPACE_APP_URI,
584
- mimeType: RESOURCE_MIME_TYPE,
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
- processSessionId: snapshot.sessionId,
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 McpSessionRegistry();
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 ProcessSessionManager();
1810
+ const processSessions = new ProcessManager();
1745
1811
  const localAgentProviders = config.subagents
1746
1812
  ? getLocalAgentProviderAvailabilitySnapshot()
1747
1813
  : [];
1748
- const logSessionCloseResults = (reason, results) => {
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", "mcp_session_close_failed", {
1818
+ logEvent(config.logging, "warn", "mcp_transport_session_close_failed", {
1752
1819
  reason,
1753
- sessionIdPrefix: sessionIdPrefix(result.sessionId),
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
- logEvent(config.logging, "info", "mcp_session_closed", {
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
- sessionIdPrefix: sessionIdPrefix(result.sessionId),
1838
+ count: closedCount,
1763
1839
  });
1764
1840
  }
1765
1841
  };
1766
- const sessionCleanupTimer = setInterval(() => {
1842
+ const transportCleanupTimer = setInterval(() => {
1767
1843
  void transports
1768
- .closeIdle(MCP_SESSION_IDLE_TIMEOUT_MS)
1769
- .then((results) => logSessionCloseResults("idle_timeout", results));
1770
- }, MCP_SESSION_CLEANUP_INTERVAL_MS);
1771
- sessionCleanupTimer.unref();
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 sessionId = req.header("mcp-session-id");
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
- method: req.method,
1845
- sessionIdPresent: Boolean(sessionId),
1846
- sessionIdPrefix: sessionIdPrefix(sessionId),
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 (sessionId) {
1852
- transport = transports.get(sessionId);
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: (newSessionId) => {
1938
+ onsessioninitialized: (newTransportSessionId) => {
1862
1939
  if (transport)
1863
- transports.register(newSessionId, transport);
1864
- logEvent(config.logging, "info", "mcp_session_created", {
1940
+ transports.register(newTransportSessionId, transport);
1941
+ logEvent(config.logging, "debug", "mcp_transport_session_created", {
1865
1942
  requestId,
1866
- sessionIdPrefix: sessionIdPrefix(newSessionId),
1943
+ transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),
1867
1944
  ...requestLogFields(req, config),
1868
1945
  });
1869
1946
  },
1870
1947
  });
1871
1948
  transport.onclose = () => {
1872
- const closedSessionId = transport?.sessionId;
1873
- if (closedSessionId && transports.remove(closedSessionId)) {
1874
- logEvent(config.logging, "info", "mcp_session_closed", {
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
- sessionIdPrefix: sessionIdPrefix(closedSessionId),
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(sessionCleanupTimer);
1983
+ clearInterval(transportCleanupTimer);
1907
1984
  const results = await transports.closeAll();
1908
- logSessionCloseResults("server_shutdown", results);
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
- process `sessionId` without killing it. The Agent can use `write_stdin` to poll,
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
 
@@ -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. Sessions idle for more than two days are returned in `staleWorkspaces` so
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
- `sessionId`. `write_stdin` can poll or interact with that session for up to another
143
- 300 seconds per call. ForgeRelay does not kill a process merely because a wait
144
- window expires. Completed background processes are delivered once with a later
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/session context, and compact operation results while
340
- keeping HTTP request records off by default. Shell command previews are enabled
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, and workspace resume/stale-session schema;
63
- 7. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash` through `ProcessSessionManager`, and a deliberate failed `edit`;
64
- 8. OS temp-directory `write` `read` `edit` `rename` `delete` over the same real MCP session, plus rejection of an arbitrary path outside the workspace/temp roots;
65
- 9. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
66
- 10. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
67
- 11. deterministic local subagent error path,不联系任何模型 provider;
68
- 12. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
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 ChatGPT Apps widget metadata.
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