@akira-tl/forgerelay 0.3.1 → 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,28 @@ 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
+
18
+ ## [0.3.2] - 2026-08-10
19
+
20
+ ### Added
21
+
22
+ - Added a ForgeRelay-owned Capability Registry and the single `capability` MCP gateway with `describe` / `run`, stable dotted names, runtime availability, validated input contracts, guide metadata, and stable diagnostic error codes.
23
+ - `open_workspace` now returns a lightweight Capability catalog on every open. The first tracer capability, `hooks.check`, validates active global/project Hook configuration without requiring Agents to route through shell or the CLI.
24
+
25
+ ### Changed
26
+
27
+ - Server instructions now direct Agents to use the Capability catalog for low-frequency actions, describing and reading only an unfamiliar capability's advertised guide before first use instead of preloading all low-frequency instructions.
28
+
7
29
  ## [0.3.1] - 2026-08-10
8
30
 
9
31
  ### Changed
@@ -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 为准。本指南提供流程和边界,不额外创造隐藏执行入口。
@@ -36,4 +36,6 @@ Hook report 会随工具结果返回给 Host/Agent。`report: false` 只隐藏
36
36
 
37
37
  ## 检查入口
38
38
 
39
- 使用 `forgerelay hooks list` 查看已发现规则,使用 `forgerelay hooks check` 做只读校验。排查 Hook 时优先确认配置来源、event/matcher 是否命中、handler 的实际退出状态,以及 tool result 中的 Hook report。
39
+ Agent 打开工作区后会在 Capability catalog 中看到 `hooks.check`。已经熟悉 contract 时可直接通过 `capability` 执行;不熟悉时先 `capability(action="describe")` 查看参数与本指南路径,再按需读取本指南。`hooks.check` 是只读检查,只接受空参数对象,并返回当前生效的全局/项目 Hook 数量;无效项目 Hook 会作为稳定的 capability execution error 返回。
40
+
41
+ CLI 仍保留给人工终端或兼容工作流:使用 `forgerelay hooks list` 查看已发现规则,使用 `forgerelay hooks check` 做只读校验。排查 Hook 时优先确认配置来源、event/matcher 是否命中、handler 的实际退出状态,以及 tool result 中的 Hook report。
@@ -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.",
@@ -0,0 +1,176 @@
1
+ import { z } from "zod";
2
+ export class CapabilityError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "CapabilityError";
8
+ }
9
+ }
10
+ export class CapabilityRegistry {
11
+ definitions;
12
+ constructor(definitions) {
13
+ this.definitions = new Map(definitions.map((definition) => [definition.name, definition]));
14
+ }
15
+ catalog(context) {
16
+ return [...this.definitions.values()].map((definition) => {
17
+ const guide = context.guides.find((candidate) => candidate.name === definition.guideName);
18
+ const availability = definition.availability(context);
19
+ const available = Boolean(guide) && availability.available;
20
+ const unavailableReason = !guide
21
+ ? `Capability guide ${definition.guideName} is unavailable.`
22
+ : availability.reason;
23
+ return {
24
+ name: definition.name,
25
+ description: definition.description,
26
+ available,
27
+ ...(!available && unavailableReason ? { unavailableReason } : {}),
28
+ guide: {
29
+ name: definition.guideName,
30
+ path: guide?.path ?? "",
31
+ readBeforeFirstUse: definition.readGuideBeforeFirstUse,
32
+ },
33
+ };
34
+ }).filter((entry) => entry.available);
35
+ }
36
+ describe(name, context) {
37
+ const definition = this.requireDefinition(name);
38
+ const catalogEntry = this.catalogEntry(definition, context);
39
+ const guide = context.guides.find((candidate) => candidate.name === definition.guideName);
40
+ if (!guide) {
41
+ throw new CapabilityError("capability_unavailable", `Capability ${name} is unavailable: capability guide ${definition.guideName} is unavailable.`);
42
+ }
43
+ return {
44
+ ...catalogEntry,
45
+ guide: {
46
+ ...catalogEntry.guide,
47
+ description: guide.description,
48
+ whenToRead: guide.whenToRead,
49
+ },
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
+ : {}),
59
+ };
60
+ }
61
+ async run(name, argumentsValue, context, options = {}) {
62
+ const definition = this.requireDefinition(name);
63
+ const catalogEntry = this.catalogEntry(definition, context);
64
+ if (!catalogEntry.available) {
65
+ throw new CapabilityError("capability_unavailable", `Capability ${name} is unavailable${catalogEntry.unavailableReason ? `: ${catalogEntry.unavailableReason}` : "."}`);
66
+ }
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);
74
+ if (!parsed.success) {
75
+ const details = parsed.error.issues
76
+ .map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "arguments"}: ${issue.message}`)
77
+ .join("; ");
78
+ throw new CapabilityError("invalid_arguments", `Invalid arguments for capability ${name}: ${details}`);
79
+ }
80
+ try {
81
+ return await definition.run(parsed.data, context);
82
+ }
83
+ catch (error) {
84
+ if (error instanceof CapabilityError)
85
+ throw error;
86
+ throw new CapabilityError("execution_failed", `Capability ${name} failed: ${error instanceof Error ? error.message : String(error)}`);
87
+ }
88
+ }
89
+ requireDefinition(name) {
90
+ const definition = this.definitions.get(name);
91
+ if (!definition) {
92
+ throw new CapabilityError("unknown_capability", `Unknown capability: ${name}`);
93
+ }
94
+ return definition;
95
+ }
96
+ catalogEntry(definition, context) {
97
+ const guide = context.guides.find((candidate) => candidate.name === definition.guideName);
98
+ const availability = definition.availability(context);
99
+ const available = Boolean(guide) && availability.available;
100
+ const unavailableReason = !guide
101
+ ? `Capability guide ${definition.guideName} is unavailable.`
102
+ : availability.reason;
103
+ return {
104
+ name: definition.name,
105
+ description: definition.description,
106
+ available,
107
+ ...(!available && unavailableReason ? { unavailableReason } : {}),
108
+ guide: {
109
+ name: definition.guideName,
110
+ path: guide?.path ?? "",
111
+ readBeforeFirstUse: definition.readGuideBeforeFirstUse,
112
+ },
113
+ };
114
+ }
115
+ }
116
+ export function createCapabilityRegistry(dependencies) {
117
+ const hooksCheckInput = z.object({}).strict();
118
+ return new CapabilityRegistry([
119
+ {
120
+ name: "hooks.check",
121
+ description: "Validate the active ForgeRelay Hook configuration for this workspace.",
122
+ guideName: "lifecycle-hooks",
123
+ readGuideBeforeFirstUse: true,
124
+ inputSchema: hooksCheckInput,
125
+ availability: () => ({ available: true }),
126
+ run: async (_input, context) => ({
127
+ value: {
128
+ ok: true,
129
+ ...await dependencies.inspectHooks(context.workspaceRoot),
130
+ },
131
+ }),
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
+ : []),
172
+ ]);
173
+ }
174
+ function isRecord(value) {
175
+ return typeof value === "object" && value !== null && !Array.isArray(value);
176
+ }
package/dist/hook-cli.js CHANGED
@@ -1,6 +1,18 @@
1
1
  import { resolve } from "node:path";
2
2
  import { HOOK_EVENTS, loadProjectHookConfig, mergeHookConfigs, parseHookConfig, } from "./hooks.js";
3
3
  import { loadForgeRelayFiles } from "./user-config.js";
4
+ export async function checkHookConfiguration(projectRoot, globalHooks = loadGlobalHooks()) {
5
+ const globalEntries = flattenHooks(globalHooks, "global");
6
+ const project = await loadProjectHookConfig(projectRoot);
7
+ if (project.diagnostic) {
8
+ throw new Error(`Hook check failed: ${project.diagnostic}`);
9
+ }
10
+ const projectEntries = flattenHooks(project.hooks, "project");
11
+ return {
12
+ globalHooks: globalEntries.length,
13
+ projectHooks: projectEntries.length,
14
+ };
15
+ }
4
16
  export async function runHooksCommand(args) {
5
17
  const [subcommand, ...rest] = args;
6
18
  if (!subcommand || ["help", "--help", "-h"].includes(subcommand)) {
@@ -12,6 +12,7 @@ export const toolNames = {
12
12
  ls: "ls",
13
13
  shell: "bash",
14
14
  writeStdin: "write_stdin",
15
+ capability: "capability",
15
16
  };
16
17
  export function buildShellMutationPolicy() {
17
18
  return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. They may also perform external device or hardware mutations when the user's current request explicitly asks for the actual device-changing operation, including firmware flashing or equivalent persistent device updates; do not infer such authorization from a check, audit, probe, backup, verification, dry-run, or build-only request. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
@@ -43,7 +44,7 @@ function capabilityContractInstructions(config) {
43
44
  : ` If ${toolNames.openWorkspace} reports logical workspaces idle for more than two days, let the user choose whether to resume or close them with ${toolNames.closeWorkspace}; never close them automatically.`;
44
45
  const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse the workspaceId returned by ${toolNames.openWorkspace} for this conversation; resume another logical workspaceId only when the user wants that workspace, and request a new logical workspace only when explicitly asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases a logical workspace; ${toolNames.closeWorktree} finalizes a managed worktree. Read the managed-worktrees capability guide for advanced worktree lifecycle and failure semantics.`;
45
46
  const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
46
- const capabilityGuides = `When ${toolNames.openWorkspace} returns capability guides, use ${toolNames.read} to load only a task-relevant guide; do not preload all guides.`;
47
+ const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
47
48
  const skills = config.skillsEnabled
48
49
  ? `When a task matches an available skill from ${toolNames.openWorkspace}, read its advertised path before proceeding. Outside normal file roots, ${toolNames.read} permits only advertised entry files and files under already-loaded advertised directories.`
49
50
  : "";
package/dist/server.js CHANGED
@@ -15,12 +15,15 @@ import express from "express";
15
15
  import * as z from "zod/v4";
16
16
  import { applyPatch } from "./apply-patch.js";
17
17
  import { buildCapabilityFingerprint } from "./capabilities.js";
18
+ import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
18
19
  import { deletePath, renamePath } from "./file-mutations.js";
19
- import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
20
+ import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
21
+ import { ArtifactError } from "./artifact-error.js";
20
22
  import { loadConfig } from "./config.js";
21
23
  import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
24
+ import { checkHookConfiguration } from "./hook-cli.js";
22
25
  import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
23
- import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
26
+ import { createOpenAIIncomingArtifactAdapter, IncomingArtifactAdapterRegistry, } from "./incoming-artifacts.js";
24
27
  import { logEvent, requestIp, requestPath, commandPreview, transportSessionIdPrefix, workspaceLogLabel, } from "./logger.js";
25
28
  import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
26
29
  import { SingleUserOAuthProvider } from "./oauth-provider.js";
@@ -64,7 +67,7 @@ function shouldAttachWidget(mode, kind) {
64
67
  case "off":
65
68
  return false;
66
69
  case "changes":
67
- return kind === "workspace" || kind === "show_changes";
70
+ return kind === "workspace" || kind === "show_changes" || kind === "capability";
68
71
  case "full":
69
72
  return true;
70
73
  }
@@ -124,6 +127,22 @@ const capabilityGuideOutputSchema = z.object({
124
127
  whenToRead: z.string(),
125
128
  path: z.string(),
126
129
  });
130
+ const capabilityCatalogGuideOutputSchema = z.object({
131
+ name: z.string(),
132
+ path: z.string(),
133
+ readBeforeFirstUse: z.boolean(),
134
+ });
135
+ const capabilityCatalogOutputSchema = z.object({
136
+ name: z.string(),
137
+ description: z.string(),
138
+ available: z.boolean(),
139
+ unavailableReason: z.string().optional(),
140
+ guide: capabilityCatalogGuideOutputSchema,
141
+ });
142
+ const capabilityErrorOutputSchema = z.object({
143
+ code: z.string(),
144
+ message: z.string(),
145
+ });
127
146
  const workspaceAgentsFileOutputSchema = z.object({
128
147
  path: z.string(),
129
148
  content: z.string(),
@@ -492,6 +511,25 @@ function workspaceHookInvocation(workspace) {
492
511
  sourceRoot: workspace.sourceRoot,
493
512
  };
494
513
  }
514
+ function capabilityContextFor(workspace) {
515
+ return {
516
+ workspaceId: workspace.id,
517
+ workspaceRoot: workspace.root,
518
+ guides: workspace.capabilityGuides.map((guide) => ({
519
+ name: guide.name,
520
+ description: guide.description,
521
+ whenToRead: guide.whenToRead,
522
+ path: formatPathForPrompt(guide.filePath),
523
+ })),
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
+ }
495
533
  function toolResultIsError(result) {
496
534
  return typeof result === "object" && result !== null && result.isError === true;
497
535
  }
@@ -649,6 +687,67 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
649
687
  export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
650
688
  const toolDescriptions = buildToolDescriptions(config);
651
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";
693
+ const capabilityRegistry = createCapabilityRegistry({
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
+ },
750
+ });
652
751
  const server = new McpServer({
653
752
  name: "forgerelay",
654
753
  title: "ForgeRelay",
@@ -740,6 +839,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
740
839
  managed: z.boolean(),
741
840
  })),
742
841
  capabilityFingerprint: capabilityFingerprintOutputSchema,
842
+ capabilityCatalog: z.array(capabilityCatalogOutputSchema),
743
843
  capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
744
844
  agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
745
845
  availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
@@ -786,6 +886,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
786
886
  whenToRead: guide.whenToRead,
787
887
  path: formatPathForPrompt(guide.filePath),
788
888
  }));
889
+ const capabilityCatalog = capabilityRegistry.catalog(capabilityContextFor(workspace));
789
890
  const cardAgentProviders = config.subagents ? localAgentProviders : [];
790
891
  const cardAgents = workspace.agentProfiles.map((profile) => {
791
892
  const summary = summarizeLocalAgentProfile(profile);
@@ -847,6 +948,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
847
948
  visibleSkills.length > 0
848
949
  ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
849
950
  : undefined,
951
+ capabilityCatalog.length > 0
952
+ ? `Optional capabilities: ${capabilityCatalog.map((entry) => entry.name).join(", ")}`
953
+ : undefined,
850
954
  visibleCapabilityGuides.length > 0
851
955
  ? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
852
956
  : undefined,
@@ -893,6 +997,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
893
997
  worktrees: knownWorktrees,
894
998
  staleWorkspaces,
895
999
  capabilityFingerprint,
1000
+ capabilityCatalog,
896
1001
  agentsFiles: cardAgentsFiles,
897
1002
  availableAgentsFiles: cardAvailableAgentsFiles,
898
1003
  skills: cardSkills,
@@ -904,6 +1009,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
904
1009
  agentsFiles: cardAgentsFiles.length,
905
1010
  availableAgentsFiles: cardAvailableAgentsFiles.length,
906
1011
  skills: cardSkills.length,
1012
+ capabilities: capabilityCatalog.length,
907
1013
  agentProviders: cardAgentProviders.length,
908
1014
  agents: cardAgents.length,
909
1015
  },
@@ -918,6 +1024,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
918
1024
  worktrees: knownWorktrees,
919
1025
  staleWorkspaces,
920
1026
  capabilityFingerprint,
1027
+ capabilityCatalog,
921
1028
  ...(includeBootstrapContext
922
1029
  ? {
923
1030
  capabilityGuides: visibleCapabilityGuides,
@@ -933,6 +1040,130 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
933
1040
  },
934
1041
  }, hookReports));
935
1042
  });
1043
+ registerAppTool(server, toolNames.capability, {
1044
+ title: "Use optional capability",
1045
+ description: "Describe or run one optional ForgeRelay capability advertised by open_workspace. Use describe when the capability contract is unfamiliar, then read its advertised guide if needed. Run dispatches only explicitly registered capabilities; it cannot invoke arbitrary shell commands, URLs, or methods.",
1046
+ inputSchema: {
1047
+ workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
1048
+ name: z
1049
+ .string()
1050
+ .regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/)
1051
+ .describe("Stable dotted capability name advertised by open_workspace."),
1052
+ action: z.enum(["describe", "run"]),
1053
+ arguments: z
1054
+ .record(z.string(), z.unknown())
1055
+ .optional()
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."),
1061
+ },
1062
+ outputSchema: {
1063
+ name: z.string(),
1064
+ action: z.enum(["describe", "run"]),
1065
+ capability: z.unknown().optional(),
1066
+ result: z.unknown().optional(),
1067
+ error: capabilityErrorOutputSchema.optional(),
1068
+ },
1069
+ _meta: {
1070
+ ...toolWidgetDescriptorMeta(config, "capability")._meta,
1071
+ "openai/fileParams": ["file"],
1072
+ },
1073
+ annotations: {
1074
+ readOnlyHint: false,
1075
+ destructiveHint: false,
1076
+ idempotentHint: false,
1077
+ openWorldHint: true,
1078
+ },
1079
+ }, async ({ workspaceId, name, action, arguments: capabilityArguments, file }) => {
1080
+ const workspace = workspaces.getWorkspace(workspaceId);
1081
+ let changedPaths = [];
1082
+ return runToolWithHooks(hooks, {
1083
+ tool: toolNames.capability,
1084
+ invocation: workspaceHookInvocation(workspace),
1085
+ payload: { name, action },
1086
+ isFailure: toolResultIsError,
1087
+ changedPaths: () => changedPaths,
1088
+ operation: async () => {
1089
+ const startedAt = performance.now();
1090
+ try {
1091
+ if (action === "describe") {
1092
+ const capability = capabilityRegistry.describe(name, capabilityContextFor(workspace));
1093
+ const result = {
1094
+ content: [textBlock([
1095
+ `${capability.name}: ${capability.description}`,
1096
+ `Available: ${capability.available}`,
1097
+ `Guide: ${capability.guide.path}`,
1098
+ capability.guide.readBeforeFirstUse
1099
+ ? "Read the guide before first use when this contract is unfamiliar."
1100
+ : undefined,
1101
+ ].filter(Boolean).join("\n"))],
1102
+ structuredContent: { name, action, capability },
1103
+ };
1104
+ logToolCall(config, {
1105
+ tool: toolNames.capability,
1106
+ ...workspaceLogContext(workspace),
1107
+ capability: name,
1108
+ action,
1109
+ success: true,
1110
+ durationMs: Math.round(performance.now() - startedAt),
1111
+ });
1112
+ return result;
1113
+ }
1114
+ const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace), { nativeFile: file });
1115
+ changedPaths = execution.changedPaths ?? [];
1116
+ const result = {
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 },
1132
+ };
1133
+ logToolCall(config, {
1134
+ tool: toolNames.capability,
1135
+ ...workspaceLogContext(workspace),
1136
+ capability: name,
1137
+ action,
1138
+ success: true,
1139
+ durationMs: Math.round(performance.now() - startedAt),
1140
+ });
1141
+ return result;
1142
+ }
1143
+ catch (error) {
1144
+ const capabilityError = error instanceof CapabilityError
1145
+ ? error
1146
+ : new CapabilityError("execution_failed", error instanceof Error ? error.message : String(error));
1147
+ const result = {
1148
+ content: [textBlock(`${capabilityError.code}: ${capabilityError.message}`)],
1149
+ structuredContent: {
1150
+ name,
1151
+ action,
1152
+ error: { code: capabilityError.code, message: capabilityError.message },
1153
+ },
1154
+ isError: true,
1155
+ };
1156
+ logFailedToolResponse(config, {
1157
+ tool: toolNames.capability,
1158
+ ...workspaceLogContext(workspace),
1159
+ capability: name,
1160
+ action,
1161
+ }, result.content, startedAt);
1162
+ return result;
1163
+ }
1164
+ },
1165
+ });
1166
+ });
936
1167
  registerAppTool(server, toolNames.closeWorkspace, {
937
1168
  title: "Close logical workspace",
938
1169
  description: "Release one logical workspaceId after the user chooses cleanup. This does not delete checkout files. Use close_worktree to finalize and remove a managed worktree. Running or unconsumed processes prevent closure.",
@@ -1509,11 +1740,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1509
1740
  invocation: workspaceHookInvocation(workspace),
1510
1741
  operation: async () => {
1511
1742
  const startedAt = performance.now();
1512
- const review = await reviewCheckpoints.reviewChanges({
1513
- workspaceId,
1514
- root: workspace.root,
1515
- markReviewed: true,
1516
- });
1743
+ const review = await reviewWorkspaceChanges(reviewCheckpoints, workspace);
1517
1744
  const content = [textBlock(review.result)];
1518
1745
  logToolCall(config, {
1519
1746
  tool: "show_changes",
@@ -1815,6 +2042,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1815
2042
  workspaces,
1816
2043
  hooks,
1817
2044
  incomingArtifactAdapters,
2045
+ incomingArtifactRegistry,
1818
2046
  });
1819
2047
  }
1820
2048
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.3.1",
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",
@@ -42,7 +42,7 @@
42
42
  "debug:accept": "node scripts/debug/accept.mjs",
43
43
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
44
44
  "start": "node dist/cli.js serve",
45
- "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.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
+ "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.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",
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
47
  "release:check": "node scripts/release-version.mjs check",
48
48
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -129,7 +129,7 @@ try {
129
129
  params: {},
130
130
  }).message.result.tools;
131
131
  const toolNames = tools.map((tool) => tool.name);
132
- for (const expected of ["open_workspace", "close_workspace", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "write_stdin"]) {
132
+ for (const expected of ["open_workspace", "close_workspace", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "write_stdin", "capability"]) {
133
133
  assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
134
134
  }
135
135
  const bashTool = tools.find((tool) => tool.name === "bash");
@@ -156,6 +156,7 @@ try {
156
156
  assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
157
157
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.staleWorkspaces);
158
158
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityFingerprint);
159
+ assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityCatalog);
159
160
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityGuides);
160
161
  const templateUri = bashTool?._meta?.ui?.resourceUri;
161
162
  assert.match(
@@ -240,6 +241,34 @@ try {
240
241
  "ui.mcp-app",
241
242
  ],
242
243
  });
244
+ const capabilityCatalog = opened.structuredContent.capabilityCatalog;
245
+ assert.deepEqual(capabilityCatalog.map((entry) => entry.name), ["hooks.check"]);
246
+ assert.equal(capabilityCatalog[0].available, true);
247
+ assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
248
+ const directCapability = callTool(oauth.accessToken, sessionId, 79, "capability", {
249
+ workspaceId,
250
+ name: "hooks.check",
251
+ action: "run",
252
+ arguments: {},
253
+ });
254
+ assert.equal(directCapability.isError, undefined);
255
+ assert.equal(directCapability.structuredContent.result.ok, true);
256
+ const describedCapability = callTool(oauth.accessToken, sessionId, 80, "capability", {
257
+ workspaceId,
258
+ name: "hooks.check",
259
+ action: "describe",
260
+ });
261
+ assert.equal(describedCapability.isError, undefined);
262
+ assert.equal(describedCapability.structuredContent.capability.guide.name, "lifecycle-hooks");
263
+ assert.equal(describedCapability.structuredContent.capability.inputSchema.type, "object");
264
+ const unknownCapability = callTool(oauth.accessToken, sessionId, 81, "capability", {
265
+ workspaceId,
266
+ name: "unknown.capability",
267
+ action: "run",
268
+ arguments: {},
269
+ });
270
+ assert.equal(unknownCapability.isError, true);
271
+ assert.equal(unknownCapability.structuredContent.error.code, "unknown_capability");
243
272
  const capabilityGuides = opened.structuredContent.capabilityGuides;
244
273
  assert.deepEqual(capabilityGuides.map((guide) => guide.name), [
245
274
  "lifecycle-hooks",
@@ -253,7 +282,7 @@ try {
253
282
  });
254
283
  assert.match(hooksGuide.structuredContent.result, /BeforeTool/);
255
284
  assert.match(hooksGuide.structuredContent.result, /BeforeWorktreeClose/);
256
- pass("open_workspace", `${workspaceId} -> fingerprint + ${capabilityGuides.length} capability guides`);
285
+ pass("open_workspace", `${workspaceId} -> ${capabilityCatalog.length} capabilities + ${capabilityGuides.length} capability guides`);
257
286
 
258
287
  const written = callTool(oauth.accessToken, sessionId, 4, "write", {
259
288
  workspaceId,