@akira-tl/forgerelay 0.3.2 → 0.3.3

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,17 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.3] - 2026-08-10
8
+
9
+ ### Added
10
+
11
+ - Added `review.changes` and `artifact.download` as registered Capability Gateway actions. Review preserves the existing Git-backed checkpoint and diff-card metadata; native artifact ingress preserves Host-native file transport, workspace-relative no-overwrite publication, size limits, and `AfterFileChange` lifecycle reporting.
12
+
13
+ ### Changed
14
+
15
+ - The Capability catalog now advertises only capabilities that are actually available in the current runtime. Explicit calls to known-but-disabled capabilities still return stable `capability_unavailable` diagnostics.
16
+ - `show_changes` and `download_artifact` remain compatibility aliases for the 0.3.3 migration window, but the artifacts/review guide now treats the Capability Gateway as the canonical Agent workflow.
17
+
7
18
  ## [0.3.2] - 2026-08-10
8
19
 
9
20
  ### Added
@@ -4,15 +4,17 @@
4
4
 
5
5
  ## Native artifact transfer
6
6
 
7
- Native artifact download 默认关闭。只有 `tools/list` 实际暴露 `download_artifact` 时才调用它;当前安全发布实现主要面向 Linux。
7
+ Native artifact download 默认关闭,且当前安全发布实现主要面向 Linux。只有 `open_workspace` Capability catalog 广告 `artifact.download` 时才执行它;不熟悉 contract 时先 `capability(action="describe")`,其中会明确 native file 通过 Gateway 顶层 `file` transport slot 传入。
8
8
 
9
9
  典型流程:
10
10
 
11
11
  1. 先用 `open_workspace` 打开目标项目;
12
- 2. 调用 `download_artifact`,传入 Host 原样提供的 native `file` 值、现有 `workspaceId` workspace-relative `path`;
12
+ 2. 调用 `capability`,`name="artifact.download"`、`action="run"`,把 Host 原样提供的 native `file` 值放在 Gateway 顶层 `file`,并在 `arguments.path` workspace-relative 目标路径;
13
13
  3. 成功后使用返回的 normalized relative path 配合普通 `read` / `rename` / `delete` / shell 等工具继续处理。
14
14
 
15
- `download_artifact` 会创建缺失的父目录,但拒绝覆盖已有目标。不要把 native file 值改造成:
15
+ 0.3.3 仍保留 `download_artifact` 作为迁移期 compatibility alias,但新的 Agent workflow 不应把它视为长期 public surface。
16
+
17
+ `artifact.download` 会创建缺失的父目录,但拒绝覆盖已有目标。不要把 native file 值改造成:
16
18
 
17
19
  - 任意 URL 字符串;
18
20
  - 猜测的本地主机路径;
@@ -28,15 +30,15 @@ Native artifact download 默认关闭。只有 `tools/list` 实际暴露 `downlo
28
30
  `FORGERELAY_WIDGETS`:
29
31
 
30
32
  - `full`:默认,为常用 workspace/file/edit/shell tools 附加 MCP App UI;
31
- - `changes`:聚焦 `open_workspace` + `show_changes` 聚合 review;
33
+ - `changes`:聚焦 `open_workspace` + `review.changes` 聚合 review;
32
34
  - `off`:不附加 widget UI。
33
35
 
34
36
  Plain MCP Host 可以忽略这些 UI metadata;不要把 UI 是否显示当成文件操作是否成功的判据。
35
37
 
36
- `tools/list` 暴露 `show_changes` 时,它自己的 tool description 是调用契约:本轮成功修改文件后,在最后一个相关 file mutation 之后、final response 之前调用一次,让用户看到聚合 diff;不要每改一个文件就调用一次。
38
+ Capability catalog 广告 `review.changes` 时,本轮成功修改文件后,在最后一个相关 file mutation 之后、final response 之前通过 `capability` 调用一次,让用户看到聚合 diff;不要每改一个文件就调用一次。0.3.3 仍保留 `show_changes` compatibility alias,但它和 `review.changes` 共用同一套 checkpoint,不是两套 review 状态。
37
39
 
38
- `show_changes` 使用 Git-backed review checkpoint。它按 workspace 跟踪 open/baseline 状态,展示自上次 review checkpoint 以来的 coherent diff,并在成功 review 后推进 baseline。当前版本要求可用的 Git workspace;checkpoint 缺失或 root 不匹配时会明确失败或使用受控 fallback,而不是凭空重建历史。
40
+ `review.changes` 使用 Git-backed review checkpoint。它按 workspace 跟踪 open/baseline 状态,展示自上次 review checkpoint 以来的 coherent diff,并在成功 review 后推进 baseline。当前版本要求可用的 Git workspace;checkpoint 缺失或 root 不匹配时会明确失败或使用受控 fallback,而不是凭空重建历史。
39
41
 
40
42
  ## Capability ownership
41
43
 
42
- Artifact transfer 和 change review 都属于 ForgeRelay capability,而不是 Agent 自己的文件搬运协议。真实可调用工具仍以 `tools/list` 为准;本指南只提供低频流程和边界,不代表隐藏工具。
44
+ Artifact transfer 和 change review 都属于 ForgeRelay registered capability,而不是 Agent 自己的文件搬运协议。`tools/list` 只负责暴露稳定 Gateway 与当前兼容 aliases;真正可用的低频能力以当前 workspace 的 Capability catalog 为准。本指南提供流程和边界,不额外创造隐藏执行入口。
@@ -32,8 +32,9 @@ const openAIFileReferenceInputSchema = z.strictObject({
32
32
  export function isArtifactDownloadSupportedPlatform(platform = process.platform) {
33
33
  return ARTIFACT_DOWNLOAD_PLATFORMS.has(platform);
34
34
  }
35
- export function registerArtifactTools(server, { config, workspaces, hooks, incomingArtifactAdapters = [], }) {
36
- const incomingRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
35
+ export function registerArtifactTools(server, { config, workspaces, hooks, incomingArtifactAdapters = [], incomingArtifactRegistry, }) {
36
+ const incomingRegistry = incomingArtifactRegistry
37
+ ?? new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
37
38
  registerAppTool(server, "download_artifact", {
38
39
  title: "Download attached or generated file",
39
40
  description: "Stream one MCP-host-provided native file to a requested relative path inside an already-open workspace. Existing destinations, arbitrary URLs, absolute paths, traversal, symlinked parents, local source paths, and malformed file objects are rejected.",
@@ -31,7 +31,7 @@ export class CapabilityRegistry {
31
31
  readBeforeFirstUse: definition.readGuideBeforeFirstUse,
32
32
  },
33
33
  };
34
- });
34
+ }).filter((entry) => entry.available);
35
35
  }
36
36
  describe(name, context) {
37
37
  const definition = this.requireDefinition(name);
@@ -48,15 +48,29 @@ export class CapabilityRegistry {
48
48
  whenToRead: guide.whenToRead,
49
49
  },
50
50
  inputSchema: z.toJSONSchema(definition.inputSchema, { target: "draft-7" }),
51
+ ...(definition.nativeFileArgument
52
+ ? {
53
+ transport: {
54
+ nativeFileArgument: definition.nativeFileArgument,
55
+ gatewayParameter: "file",
56
+ },
57
+ }
58
+ : {}),
51
59
  };
52
60
  }
53
- async run(name, argumentsValue, context) {
61
+ async run(name, argumentsValue, context, options = {}) {
54
62
  const definition = this.requireDefinition(name);
55
63
  const catalogEntry = this.catalogEntry(definition, context);
56
64
  if (!catalogEntry.available) {
57
65
  throw new CapabilityError("capability_unavailable", `Capability ${name} is unavailable${catalogEntry.unavailableReason ? `: ${catalogEntry.unavailableReason}` : "."}`);
58
66
  }
59
- const parsed = definition.inputSchema.safeParse(argumentsValue ?? {});
67
+ if (options.nativeFile !== undefined && !definition.nativeFileArgument) {
68
+ throw new CapabilityError("invalid_arguments", `Capability ${name} does not accept a Host-native file value.`);
69
+ }
70
+ const input = definition.nativeFileArgument && options.nativeFile !== undefined
71
+ ? { ...(isRecord(argumentsValue) ? argumentsValue : {}), [definition.nativeFileArgument]: options.nativeFile }
72
+ : argumentsValue ?? {};
73
+ const parsed = definition.inputSchema.safeParse(input);
60
74
  if (!parsed.success) {
61
75
  const details = parsed.error.issues
62
76
  .map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "arguments"}: ${issue.message}`)
@@ -110,9 +124,53 @@ export function createCapabilityRegistry(dependencies) {
110
124
  inputSchema: hooksCheckInput,
111
125
  availability: () => ({ available: true }),
112
126
  run: async (_input, context) => ({
113
- ok: true,
114
- ...await dependencies.inspectHooks(context.workspaceRoot),
127
+ value: {
128
+ ok: true,
129
+ ...await dependencies.inspectHooks(context.workspaceRoot),
130
+ },
115
131
  }),
116
132
  },
133
+ ...(dependencies.reviewChanges
134
+ ? [{
135
+ name: "review.changes",
136
+ description: "Review accumulated workspace changes from the Git-backed review checkpoint.",
137
+ guideName: "artifacts-review",
138
+ readGuideBeforeFirstUse: true,
139
+ inputSchema: z.object({}).strict(),
140
+ availability: () => ({
141
+ available: dependencies.reviewChanges?.available ?? false,
142
+ reason: dependencies.reviewChanges?.unavailableReason,
143
+ }),
144
+ run: async (_input, context) => dependencies.reviewChanges.run(context),
145
+ }]
146
+ : []),
147
+ ...(dependencies.downloadArtifact
148
+ ? [{
149
+ name: "artifact.download",
150
+ description: "Save one Host-native file into a workspace-relative destination without overwriting.",
151
+ guideName: "artifacts-review",
152
+ readGuideBeforeFirstUse: true,
153
+ inputSchema: z.object({
154
+ file: z.strictObject({
155
+ download_url: z.string(),
156
+ file_id: z.string(),
157
+ mime_type: z.string().nullable().optional(),
158
+ file_name: z.string().nullable().optional(),
159
+ name: z.string().nullable().optional(),
160
+ size: z.number().int().nonnegative().nullable().optional(),
161
+ }),
162
+ path: z.string().min(1),
163
+ }).strict(),
164
+ nativeFileArgument: "file",
165
+ availability: () => ({
166
+ available: dependencies.downloadArtifact?.available ?? false,
167
+ reason: dependencies.downloadArtifact?.unavailableReason,
168
+ }),
169
+ run: async (input, context) => dependencies.downloadArtifact.run(input, context),
170
+ }]
171
+ : []),
117
172
  ]);
118
173
  }
174
+ function isRecord(value) {
175
+ return typeof value === "object" && value !== null && !Array.isArray(value);
176
+ }
package/dist/server.js CHANGED
@@ -17,12 +17,13 @@ import { applyPatch } from "./apply-patch.js";
17
17
  import { buildCapabilityFingerprint } from "./capabilities.js";
18
18
  import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
19
19
  import { deletePath, renamePath } from "./file-mutations.js";
20
- import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
20
+ import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
21
+ import { ArtifactError } from "./artifact-error.js";
21
22
  import { loadConfig } from "./config.js";
22
23
  import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
23
24
  import { checkHookConfiguration } from "./hook-cli.js";
24
25
  import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
25
- import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
26
+ import { createOpenAIIncomingArtifactAdapter, IncomingArtifactAdapterRegistry, } from "./incoming-artifacts.js";
26
27
  import { logEvent, requestIp, requestPath, commandPreview, transportSessionIdPrefix, workspaceLogLabel, } from "./logger.js";
27
28
  import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
28
29
  import { SingleUserOAuthProvider } from "./oauth-provider.js";
@@ -66,7 +67,7 @@ function shouldAttachWidget(mode, kind) {
66
67
  case "off":
67
68
  return false;
68
69
  case "changes":
69
- return kind === "workspace" || kind === "show_changes";
70
+ return kind === "workspace" || kind === "show_changes" || kind === "capability";
70
71
  case "full":
71
72
  return true;
72
73
  }
@@ -522,6 +523,13 @@ function capabilityContextFor(workspace) {
522
523
  })),
523
524
  };
524
525
  }
526
+ async function reviewWorkspaceChanges(reviewCheckpoints, workspace) {
527
+ return reviewCheckpoints.reviewChanges({
528
+ workspaceId: workspace.id,
529
+ root: workspace.root,
530
+ markReviewed: true,
531
+ });
532
+ }
525
533
  function toolResultIsError(result) {
526
534
  return typeof result === "object" && result !== null && result.isError === true;
527
535
  }
@@ -679,8 +687,66 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
679
687
  export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
680
688
  const toolDescriptions = buildToolDescriptions(config);
681
689
  const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
690
+ const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
691
+ const artifactDownloadAvailable = config.artifactsEnabled && isArtifactDownloadSupportedPlatform();
692
+ const reviewChangesAvailable = config.widgets === "changes";
682
693
  const capabilityRegistry = createCapabilityRegistry({
683
694
  inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
695
+ reviewChanges: {
696
+ available: reviewChangesAvailable,
697
+ unavailableReason: reviewChangesAvailable
698
+ ? undefined
699
+ : "Aggregate change review is disabled; start ForgeRelay with widgets=changes.",
700
+ run: async (context) => {
701
+ const review = await reviewWorkspaceChanges(reviewCheckpoints, {
702
+ id: context.workspaceId,
703
+ root: context.workspaceRoot,
704
+ });
705
+ return {
706
+ value: {
707
+ result: review.result,
708
+ summary: review.summary,
709
+ files: review.files,
710
+ },
711
+ card: {
712
+ tool: "show_changes",
713
+ summary: review.summary,
714
+ files: review.files,
715
+ payload: { patch: review.patch },
716
+ },
717
+ };
718
+ },
719
+ },
720
+ downloadArtifact: {
721
+ available: artifactDownloadAvailable,
722
+ unavailableReason: !config.artifactsEnabled
723
+ ? "Native artifact ingress is disabled."
724
+ : !isArtifactDownloadSupportedPlatform()
725
+ ? "Native artifact ingress is unsupported on this platform."
726
+ : undefined,
727
+ run: async (input, context) => {
728
+ try {
729
+ const downloaded = await downloadIncomingArtifact({
730
+ registry: incomingArtifactRegistry,
731
+ workspaceId: context.workspaceId,
732
+ workspaceRoot: context.workspaceRoot,
733
+ maxFileBytes: config.artifactMaxFileBytes,
734
+ file: input.file,
735
+ path: input.path,
736
+ });
737
+ return {
738
+ value: { path: downloaded.path },
739
+ changedPaths: [downloaded.path],
740
+ };
741
+ }
742
+ catch (error) {
743
+ if (error instanceof ArtifactError) {
744
+ throw new CapabilityError(`artifact.${error.code}`, error.message);
745
+ }
746
+ throw error;
747
+ }
748
+ },
749
+ },
684
750
  });
685
751
  const server = new McpServer({
686
752
  name: "forgerelay",
@@ -987,7 +1053,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
987
1053
  arguments: z
988
1054
  .record(z.string(), z.unknown())
989
1055
  .optional()
990
- .describe("Capability-specific arguments. Omit for describe and for capabilities with no arguments."),
1056
+ .describe("Capability-specific JSON arguments. Omit for describe and for capabilities with no arguments."),
1057
+ file: z
1058
+ .unknown()
1059
+ .optional()
1060
+ .describe("Host-native file value. Only capabilities whose describe result advertises native-file transport may consume it."),
991
1061
  },
992
1062
  outputSchema: {
993
1063
  name: z.string(),
@@ -996,20 +1066,25 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
996
1066
  result: z.unknown().optional(),
997
1067
  error: capabilityErrorOutputSchema.optional(),
998
1068
  },
999
- _meta: {},
1069
+ _meta: {
1070
+ ...toolWidgetDescriptorMeta(config, "capability")._meta,
1071
+ "openai/fileParams": ["file"],
1072
+ },
1000
1073
  annotations: {
1001
1074
  readOnlyHint: false,
1002
1075
  destructiveHint: false,
1003
1076
  idempotentHint: false,
1004
- openWorldHint: false,
1077
+ openWorldHint: true,
1005
1078
  },
1006
- }, async ({ workspaceId, name, action, arguments: capabilityArguments }) => {
1079
+ }, async ({ workspaceId, name, action, arguments: capabilityArguments, file }) => {
1007
1080
  const workspace = workspaces.getWorkspace(workspaceId);
1081
+ let changedPaths = [];
1008
1082
  return runToolWithHooks(hooks, {
1009
1083
  tool: toolNames.capability,
1010
1084
  invocation: workspaceHookInvocation(workspace),
1011
1085
  payload: { name, action },
1012
1086
  isFailure: toolResultIsError,
1087
+ changedPaths: () => changedPaths,
1013
1088
  operation: async () => {
1014
1089
  const startedAt = performance.now();
1015
1090
  try {
@@ -1036,10 +1111,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1036
1111
  });
1037
1112
  return result;
1038
1113
  }
1039
- const capabilityResult = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace));
1114
+ const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace), { nativeFile: file });
1115
+ changedPaths = execution.changedPaths ?? [];
1040
1116
  const result = {
1041
- content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(capabilityResult, null, 2)}`)],
1042
- structuredContent: { name, action, result: capabilityResult },
1117
+ content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
1118
+ ...(execution.card
1119
+ ? {
1120
+ _meta: {
1121
+ tool: execution.card.tool ?? toolNames.capability,
1122
+ card: {
1123
+ workspaceId,
1124
+ summary: execution.card.summary ?? {},
1125
+ files: execution.card.files,
1126
+ payload: execution.card.payload ?? {},
1127
+ },
1128
+ },
1129
+ }
1130
+ : {}),
1131
+ structuredContent: { name, action, result: execution.value },
1043
1132
  };
1044
1133
  logToolCall(config, {
1045
1134
  tool: toolNames.capability,
@@ -1651,11 +1740,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1651
1740
  invocation: workspaceHookInvocation(workspace),
1652
1741
  operation: async () => {
1653
1742
  const startedAt = performance.now();
1654
- const review = await reviewCheckpoints.reviewChanges({
1655
- workspaceId,
1656
- root: workspace.root,
1657
- markReviewed: true,
1658
- });
1743
+ const review = await reviewWorkspaceChanges(reviewCheckpoints, workspace);
1659
1744
  const content = [textBlock(review.result)];
1660
1745
  logToolCall(config, {
1661
1746
  tool: "show_changes",
@@ -1957,6 +2042,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1957
2042
  workspaces,
1958
2043
  hooks,
1959
2044
  incomingArtifactAdapters,
2045
+ incomingArtifactRegistry,
1960
2046
  });
1961
2047
  }
1962
2048
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",