@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 +22 -0
- package/capabilities/artifacts-review/GUIDE.md +9 -7
- package/capabilities/lifecycle-hooks/GUIDE.md +3 -1
- package/dist/artifact-tools.js +3 -2
- package/dist/capability-registry.js +176 -0
- package/dist/hook-cli.js +12 -0
- package/dist/mcp/server-instructions.js +2 -1
- package/dist/server.js +236 -8
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +31 -2
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
|
|
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. 调用 `
|
|
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`
|
|
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` + `
|
|
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
|
-
当
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
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。
|
package/dist/artifact-tools.js
CHANGED
|
@@ -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 =
|
|
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 = `
|
|
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
|
|
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.
|
|
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",
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -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} ->
|
|
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,
|