@akira-tl/forgerelay 0.2.4 → 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 CHANGED
@@ -4,6 +4,15 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.5] - 2026-08-10
8
+
9
+ ### Changed
10
+
11
+ - Human-facing `pretty` logs are workspace-first: normal tool and hook lines no longer show transient MCP transport session IDs, MCP session create/close lifecycle events are debug-only, and project names receive stable per-project terminal colors while the logical `ws_...` identifier remains visible.
12
+ - MCP App tool metadata now uses a content-hashed UI resource URI derived from the built JavaScript/CSS, advertises both `model` and `app` visibility, and includes the ChatGPT `openai/outputTemplate` compatibility alias. Legacy `ui://forgerelay/workspace-app.html` and historical `workspace-app-*.html` pointers continue to resolve to the current template so stale ChatGPT metadata snapshots do not fail with a missing resource. Debug logs now distinguish current, legacy, and historical template reads, and real acceptance exercises current/compatibility resources plus the referenced JavaScript asset.
13
+ - `~/...` paths are expanded before allowed-root resolution, so advertised skills rendered with home-relative paths can be read directly instead of being interpreted as a literal `~` directory under the workspace.
14
+ - Successful MCP session shutdown and idle-cleanup lifecycle logs moved to `debug`; individual session close failures remain visible as warnings.
15
+
7
16
  ## [0.2.4] - 2026-08-09
8
17
 
9
18
  ### Added
package/dist/logger.js CHANGED
@@ -13,6 +13,14 @@ const LEVEL_STYLE = {
13
13
  info: "green",
14
14
  debug: "gray",
15
15
  };
16
+ const WORKSPACE_PROJECT_COLORS = [
17
+ "cyanBright",
18
+ "greenBright",
19
+ "yellowBright",
20
+ "magentaBright",
21
+ "blueBright",
22
+ "whiteBright",
23
+ ];
16
24
  export function shouldLog(config, level) {
17
25
  return LEVEL_WEIGHT[config.level] >= LEVEL_WEIGHT[level];
18
26
  }
@@ -70,11 +78,13 @@ export function formatPrettyLogEntry(entry, options = {}) {
70
78
  const level = logLevel(entry.level);
71
79
  const time = formatTimestamp(entry.ts);
72
80
  const source = stringField(entry.workspace) ?? stringField(entry.workspaceId) ?? "forgerelay";
73
- const session = stringField(entry.session) ?? stringField(entry.sessionIdPrefix);
81
+ const session = level === "debug"
82
+ ? stringField(entry.session) ?? stringField(entry.sessionIdPrefix)
83
+ : undefined;
74
84
  const prefix = [
75
85
  style("gray", time, options),
76
86
  `[${style(LEVEL_STYLE[level], level.toUpperCase(), options)}]`,
77
- style(["cyan", "underline"], source, options),
87
+ formatPrettySource(source, options),
78
88
  session ? style("gray", `session:${session}`, options) : undefined,
79
89
  style("gray", "|", options),
80
90
  ].filter((value) => Boolean(value)).join(" ");
@@ -92,10 +102,18 @@ function formatPrettyMessage(entry, options) {
92
102
  return formatHookMessage(entry, options);
93
103
  case "http_request":
94
104
  return formatHttpMessage(entry, options);
105
+ case "mcp_request":
106
+ return formatMcpRequestMessage(entry);
107
+ case "mcp_app_template_read":
108
+ return formatAppTemplateMessage(entry, options, false);
109
+ case "mcp_app_template_read_failed":
110
+ return formatAppTemplateMessage(entry, options, true);
95
111
  case "mcp_session_created":
96
112
  return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} created`;
97
113
  case "mcp_session_closed":
98
114
  return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} closed`;
115
+ case "mcp_sessions_closed":
116
+ return `${numberField(entry.count) ?? 0} sessions closed`;
99
117
  case "mcp_session_close_failed":
100
118
  return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} close -> ${style("red", "error", options)}`;
101
119
  case "auth_denied":
@@ -161,6 +179,42 @@ function formatHttpMessage(entry, options) {
161
179
  const statusStyle = status !== undefined && status >= 400 ? "red" : "green";
162
180
  return `http ${method} ${path} -> ${style(statusStyle, statusText, options)}`;
163
181
  }
182
+ function formatMcpRequestMessage(entry) {
183
+ const method = stringField(entry.rpcMethod) ?? stringField(entry.httpMethod) ?? "request";
184
+ const target = stringField(entry.rpcTarget);
185
+ return target ? `mcp ${method} ${target}` : `mcp ${method}`;
186
+ }
187
+ function formatAppTemplateMessage(entry, options, failed) {
188
+ const requestedUri = stringField(entry.requestedUri) ?? "unknown";
189
+ const compatibility = stringField(entry.compatibility) ?? "unknown";
190
+ if (failed) {
191
+ const error = stringField(entry.error);
192
+ return `app template ${compatibility} ${requestedUri} -> ${style("red", error ? `error: ${error}` : "error", options)}`;
193
+ }
194
+ const currentUri = stringField(entry.currentUri);
195
+ const target = compatibility === "current" || !currentUri
196
+ ? requestedUri
197
+ : `${requestedUri} => ${currentUri}`;
198
+ return `app template ${compatibility} ${target} -> ${style("green", "ok", options)}`;
199
+ }
200
+ function formatPrettySource(source, options) {
201
+ const separator = source.lastIndexOf("/");
202
+ if (separator <= 0 || separator === source.length - 1) {
203
+ return style(["cyan", "underline"], source, options);
204
+ }
205
+ const project = source.slice(0, separator);
206
+ const workspace = source.slice(separator + 1);
207
+ const projectColor = WORKSPACE_PROJECT_COLORS[stableColorIndex(project)];
208
+ return `${style([projectColor, "bold"], project, options)}/${style(["cyan", "underline"], workspace, options)}`;
209
+ }
210
+ function stableColorIndex(value) {
211
+ let hash = 2166136261;
212
+ for (let index = 0; index < value.length; index += 1) {
213
+ hash ^= value.charCodeAt(index);
214
+ hash = Math.imul(hash, 16777619);
215
+ }
216
+ return (hash >>> 0) % WORKSPACE_PROJECT_COLORS.length;
217
+ }
164
218
  function formatGenericMessage(entry) {
165
219
  const event = String(entry.event ?? "log");
166
220
  const detail = [entry.reason, entry.error]
@@ -0,0 +1,45 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ export const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html";
4
+ export const WORKSPACE_APP_LEGACY_URI = "ui://forgerelay/workspace-app.html";
5
+ export const WORKSPACE_APP_URI_TEMPLATE = "ui://forgerelay/workspace-app-{revision}.html";
6
+ export function workspaceAppUriForRevision(revision) {
7
+ return `ui://forgerelay/workspace-app-${encodeURIComponent(revision)}.html`;
8
+ }
9
+ export function readWorkspaceAppManifestEntry(manifestUrl) {
10
+ const manifest = JSON.parse(readFileSync(manifestUrl, "utf8"));
11
+ const entry = manifest[WORKSPACE_APP_MANIFEST_ENTRY];
12
+ if (!entry?.file) {
13
+ throw new Error(`Missing ${WORKSPACE_APP_MANIFEST_ENTRY} in UI manifest.`);
14
+ }
15
+ return entry;
16
+ }
17
+ export function workspaceAppBundleRevision(entry, buildDirectoryUrl) {
18
+ const hash = createHash("sha256");
19
+ const assetPaths = [entry.file, ...(entry.css ?? [])];
20
+ for (const assetPath of assetPaths) {
21
+ hash.update(assetPath);
22
+ hash.update("\0");
23
+ hash.update(readFileSync(new URL(assetPath, buildDirectoryUrl)));
24
+ hash.update("\0");
25
+ }
26
+ return hash.digest("hex").slice(0, 12);
27
+ }
28
+ export function resolveWorkspaceAppIdentity(options) {
29
+ try {
30
+ const entry = readWorkspaceAppManifestEntry(options.manifestUrl);
31
+ const revision = workspaceAppBundleRevision(entry, options.buildDirectoryUrl);
32
+ return {
33
+ revision,
34
+ uri: workspaceAppUriForRevision(revision),
35
+ source: "bundle",
36
+ };
37
+ }
38
+ catch {
39
+ return {
40
+ revision: options.fallbackRevision,
41
+ uri: workspaceAppUriForRevision(options.fallbackRevision),
42
+ source: "fallback",
43
+ };
44
+ }
45
+ }
package/dist/roots.js CHANGED
@@ -33,7 +33,7 @@ export function assertAllowedPath(path, allowedRoots) {
33
33
  throw new AccessDeniedError(`Path is outside allowed roots: ${path}`);
34
34
  }
35
35
  export function resolveAllowedPath(inputPath, cwd, allowedRoots) {
36
- const absolutePath = resolve(cwd, inputPath);
36
+ const absolutePath = resolve(cwd, expandHomePath(inputPath));
37
37
  return assertAllowedPath(absolutePath, allowedRoots);
38
38
  }
39
39
  export async function resolveCanonicalAllowedPath(inputPath, cwd, allowedRoots) {
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";
@@ -27,6 +27,7 @@ import { McpSessionRegistry, } from "./mcp-sessions.js";
27
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: WORKSPACE_APP_URI,
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, sessionId) {
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 readWorkspaceAppManifest() {
251
- return JSON.parse(readFileSync(uiManifestUrl(), "utf8"));
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
- 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;
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}.`
@@ -568,30 +634,21 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
568
634
  artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
569
635
  }),
570
636
  });
571
- registerAppResource(server, "ForgeRelay Diff Card", WORKSPACE_APP_URI, {
637
+ const currentWorkspaceAppUri = currentWorkspaceAppIdentity().uri;
638
+ const workspaceAppResourceMetadata = {
572
639
  description: "Interactive card for viewing ForgeRelay file diffs.",
573
640
  _meta: {
574
641
  ui: {
575
642
  csp: appCsp(config),
576
643
  },
577
644
  },
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
- });
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));
595
652
  registerAppTool(server, "open_workspace", {
596
653
  title: "Open workspace",
597
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.",
@@ -944,7 +1001,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
944
1001
  path: z
945
1002
  .string()
946
1003
  .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."
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."
948
1005
  : "File path to read, relative to the workspace root or absolute inside the OS temp directory."),
949
1006
  offset: z
950
1007
  .number()
@@ -1746,6 +1803,7 @@ export function createServer(config = loadConfig(), options = {}) {
1746
1803
  ? getLocalAgentProviderAvailabilitySnapshot()
1747
1804
  : [];
1748
1805
  const logSessionCloseResults = (reason, results) => {
1806
+ let closedCount = 0;
1749
1807
  for (const result of results) {
1750
1808
  if (result.error) {
1751
1809
  logEvent(config.logging, "warn", "mcp_session_close_failed", {
@@ -1757,9 +1815,18 @@ export function createServer(config = loadConfig(), options = {}) {
1757
1815
  });
1758
1816
  continue;
1759
1817
  }
1760
- logEvent(config.logging, "info", "mcp_session_closed", {
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", {
1761
1828
  reason,
1762
- sessionIdPrefix: sessionIdPrefix(result.sessionId),
1829
+ count: closedCount,
1763
1830
  });
1764
1831
  }
1765
1832
  };
@@ -1841,10 +1908,11 @@ export function createServer(config = loadConfig(), options = {}) {
1841
1908
  }
1842
1909
  logEvent(config.logging, "debug", "mcp_request", {
1843
1910
  requestId,
1844
- method: req.method,
1911
+ httpMethod: req.method,
1845
1912
  sessionIdPresent: Boolean(sessionId),
1846
1913
  sessionIdPrefix: sessionIdPrefix(sessionId),
1847
1914
  isInitialize: initializeRequest,
1915
+ ...mcpRequestDebugFields(req.body),
1848
1916
  });
1849
1917
  try {
1850
1918
  let transport;
@@ -1861,7 +1929,7 @@ export function createServer(config = loadConfig(), options = {}) {
1861
1929
  onsessioninitialized: (newSessionId) => {
1862
1930
  if (transport)
1863
1931
  transports.register(newSessionId, transport);
1864
- logEvent(config.logging, "info", "mcp_session_created", {
1932
+ logEvent(config.logging, "debug", "mcp_session_created", {
1865
1933
  requestId,
1866
1934
  sessionIdPrefix: sessionIdPrefix(newSessionId),
1867
1935
  ...requestLogFields(req, config),
@@ -1871,7 +1939,7 @@ export function createServer(config = loadConfig(), options = {}) {
1871
1939
  transport.onclose = () => {
1872
1940
  const closedSessionId = transport?.sessionId;
1873
1941
  if (closedSessionId && transports.remove(closedSessionId)) {
1874
- logEvent(config.logging, "info", "mcp_session_closed", {
1942
+ logEvent(config.logging, "debug", "mcp_session_closed", {
1875
1943
  reason: "transport_close",
1876
1944
  sessionIdPrefix: sessionIdPrefix(closedSessionId),
1877
1945
  });
@@ -336,8 +336,11 @@ forgerelay agents show <id>
336
336
  | `FORGERELAY_TRUST_PROXY` | `0` |
337
337
 
338
338
  `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
339
+ short timestamps, workspace-first context, and compact operation results while
340
+ keeping HTTP request records off by default. Project names receive stable
341
+ per-project colors and logical `ws_...` identifiers remain visible; transient MCP
342
+ transport session IDs and normal session lifecycle events are shown only at
343
+ `debug` level. Shell command previews are enabled
341
344
  in this mode and truncated to 120 characters; set
342
345
  `FORGERELAY_LOG_SHELL_COMMANDS=0` when command arguments may contain secrets.
343
346
 
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`, the non-blanket `bash` mutation policy, no kill-timeout input, the 300-second foreground-wait contract, workspace resume/stale-session 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 `ProcessSessionManager`, and a deliberate failed `edit`;
65
+ 9. 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;
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
 
package/docs/roadmap.md CHANGED
@@ -82,6 +82,26 @@ Hooks v1 的目标是给用户和 Agent 一个很小、自动、可组合的生
82
82
 
83
83
  0.2 不引入审批 UI、HTTP/prompt/agent handler、Git 字符串解析器或插件注册表。只有出现真实需求时再扩展 handler 类型。
84
84
 
85
+ ### 0.2.5 — MCP App template reliability
86
+
87
+ 0.2.5 收敛 ChatGPT/MCP App 模板身份与排障链路:
88
+
89
+ - 当前 UI resource URI 由实际构建出的 JavaScript/CSS 内容哈希生成,而不是仅依赖 npm 版本;
90
+ - `ui://forgerelay/workspace-app.html` 继续作为 legacy pointer;
91
+ - 历史 `workspace-app-*.html` URI 通过兼容 resource template 继续读取当前模板,避免旧 metadata snapshot 直接变成 missing resource;
92
+ - debug 日志区分 current / legacy / historical template read,并保留 asset request trace;
93
+ - 7677 acceptance 覆盖 tool metadata、resource list/template list、三类 template read 和静态 bundle HTTP fetch。
94
+
95
+ ### 0.2.6 — identity and transport terminology
96
+
97
+ 0.2.6 整理 ForgeRelay 中多个 `session` 含义,不改变 workspace-first 架构:
98
+
99
+ - `workspaceId` 是唯一持久的逻辑工作身份,跨请求和 transport 重连保持连续;
100
+ - `requestId` 只追踪单次 HTTP/JSON-RPC 请求,不持久化;
101
+ - MCP 协议层 session 在内部和 debug 输出中明确称为 `transportSessionId`,业务状态不得依赖它;
102
+ - 后台命令句柄逐步迁移为 `processId` / process handle;若公开 schema 改名需要兼容窗口,则在明确的版本边界完成;
103
+ - 为 stateless MCP transport 做准备,同时保留旧协议兼容 adapter,避免把 transport 生命周期重新提升成 ForgeRelay 会话模型。
104
+
85
105
  ## 0.3 — LSP code intelligence v1
86
106
 
87
107
  LSP is moderate implementation complexity if ForgeRelay does not become a
@@ -90,6 +110,12 @@ language-server installer.
90
110
  The first version should launch only language servers already available on the
91
111
  user's machine or explicitly configured by the user/project.
92
112
 
113
+ During 0.3 development, MCP App UI hardening can land alongside the LSP work when
114
+ it does not distort the code-intelligence scope. In particular, evaluate a more
115
+ self-contained template/bootstrap bundle, reduce avoidable external chunk fetches,
116
+ and keep the current content-hash/compatibility-resource contract intact. This is
117
+ reliability work, not a requirement to move application state into the UI.
118
+
93
119
  Initial operations:
94
120
 
95
121
  - diagnostics;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -41,7 +41,7 @@
41
41
  "debug:accept": "node scripts/debug/accept.mjs",
42
42
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
43
43
  "start": "node dist/cli.js serve",
44
- "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/hooks.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
44
+ "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit",
46
46
  "release:check": "node scripts/release-version.mjs check",
47
47
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -38,7 +38,13 @@ await assertDebugPortFree();
38
38
  rmSync(acceptanceRoot, { recursive: true, force: true });
39
39
  mkdirSync(acceptanceRoot, { recursive: true });
40
40
 
41
- const { env } = createDebugEnvironment({ ownerToken, stateDir, worktreeRoot, hookLog });
41
+ const { env } = createDebugEnvironment({
42
+ ownerToken,
43
+ stateDir,
44
+ worktreeRoot,
45
+ hookLog,
46
+ widgets: "full",
47
+ });
42
48
  const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
43
49
  cwd: repoRoot,
44
50
  env,
@@ -124,8 +130,64 @@ try {
124
130
  assert.ok(openWorkspaceTool?.inputSchema?.properties?.workspaceId);
125
131
  assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
126
132
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.staleWorkspaces);
133
+ const templateUri = bashTool?._meta?.ui?.resourceUri;
134
+ assert.match(
135
+ templateUri ?? "",
136
+ /^ui:\/\/forgerelay\/workspace-app-[0-9a-f]{12}\.html$/,
137
+ JSON.stringify(bashTool ?? {}),
138
+ );
139
+ assert.deepEqual(bashTool?._meta?.ui?.visibility, ["model", "app"]);
140
+ assert.equal(bashTool?._meta?.["openai/outputTemplate"], templateUri);
127
141
  pass("MCP tools/list", `${toolNames.length} tools: ${toolNames.join(", ")}`);
128
142
 
143
+ const resources = mcpRequest(oauth.accessToken, sessionId, {
144
+ jsonrpc: "2.0",
145
+ id: 21,
146
+ method: "resources/list",
147
+ params: {},
148
+ }).message.result.resources;
149
+ assert.ok(resources.some((resource) => resource.uri === templateUri));
150
+ assert.ok(resources.some((resource) => resource.uri === "ui://forgerelay/workspace-app.html"));
151
+
152
+ const resourceTemplates = mcpRequest(oauth.accessToken, sessionId, {
153
+ jsonrpc: "2.0",
154
+ id: 22,
155
+ method: "resources/templates/list",
156
+ params: {},
157
+ }).message.result.resourceTemplates;
158
+ assert.ok(resourceTemplates.some(
159
+ (resourceTemplate) => resourceTemplate.uriTemplate === "ui://forgerelay/workspace-app-{revision}.html",
160
+ ));
161
+
162
+ const readTemplate = (id, uri) => mcpRequest(oauth.accessToken, sessionId, {
163
+ jsonrpc: "2.0",
164
+ id,
165
+ method: "resources/read",
166
+ params: { uri },
167
+ }).message.result.contents[0];
168
+
169
+ const template = readTemplate(23, templateUri);
170
+ assert.equal(template.uri, templateUri);
171
+ assert.equal(template.mimeType, "text/html;profile=mcp-app");
172
+ assert.match(template.text ?? "", /<script type="module" crossorigin src="[^"]+\/mcp-app-assets\//);
173
+ assert.ok(template._meta?.ui?.csp?.resourceDomains?.includes(debugBaseUrl));
174
+ const scriptUrl = template.text?.match(/<script type="module" crossorigin src="([^"]+)"/)?.[1];
175
+ assert.ok(scriptUrl);
176
+ const scriptAsset = curlRequest({ method: "GET", url: scriptUrl });
177
+ assert.equal(scriptAsset.status, 200, scriptAsset.body);
178
+
179
+ const legacyTemplate = readTemplate(24, "ui://forgerelay/workspace-app.html");
180
+ assert.equal(legacyTemplate.uri, "ui://forgerelay/workspace-app.html");
181
+ assert.equal(legacyTemplate.mimeType, "text/html;profile=mcp-app");
182
+ assert.equal(legacyTemplate.text, template.text);
183
+
184
+ const historicalUri = "ui://forgerelay/workspace-app-0.2.4.html";
185
+ const historicalTemplate = readTemplate(25, historicalUri);
186
+ assert.equal(historicalTemplate.uri, historicalUri);
187
+ assert.equal(historicalTemplate.mimeType, "text/html;profile=mcp-app");
188
+ assert.equal(historicalTemplate.text, template.text);
189
+ pass("MCP app template", `${templateUri} + legacy/history compatibility -> ${scriptUrl}`);
190
+
129
191
  const opened = callTool(oauth.accessToken, sessionId, 3, "open_workspace", {
130
192
  path: checkoutWorkspace,
131
193
  });
@@ -364,7 +426,13 @@ function initializeRequest(id) {
364
426
  method: "initialize",
365
427
  params: {
366
428
  protocolVersion: "2025-06-18",
367
- capabilities: {},
429
+ capabilities: {
430
+ extensions: {
431
+ "io.modelcontextprotocol/ui": {
432
+ mimeTypes: ["text/html;profile=mcp-app"],
433
+ },
434
+ },
435
+ },
368
436
  clientInfo: { name: "forgerelay-debug-acceptance", version: "1.0.0" },
369
437
  },
370
438
  };
@@ -20,6 +20,7 @@ export function createDebugEnvironment({
20
20
  stateDir = resolve(debugRoot, "state"),
21
21
  worktreeRoot = resolve(debugRoot, "worktrees"),
22
22
  hookLog = debugHookLog,
23
+ widgets = process.env.FORGERELAY_DEBUG_WIDGETS ?? "off",
23
24
  } = {}) {
24
25
  const token = ownerToken ?? process.env.FORGERELAY_DEBUG_OWNER_TOKEN ?? createDebugOwnerToken();
25
26
  mkdirSync(debugRoot, { recursive: true });
@@ -37,7 +38,7 @@ export function createDebugEnvironment({
37
38
  FORGERELAY_WORKTREE_ROOT: worktreeRoot,
38
39
  FORGERELAY_OAUTH_OWNER_TOKEN: token,
39
40
  FORGERELAY_TOOL_MODE: "full",
40
- FORGERELAY_WIDGETS: "off",
41
+ FORGERELAY_WIDGETS: widgets,
41
42
  FORGERELAY_LOG_LEVEL: process.env.FORGERELAY_LOG_LEVEL ?? "info",
42
43
  FORGERELAY_LOG_FORMAT: process.env.FORGERELAY_LOG_FORMAT ?? "pretty",
43
44
  FORGERELAY_DEBUG_HOOK_RECORDER: debugHookRecorder,