aura-basetools 0.0.0-stage → 0.13.0
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/LICENSE +21 -0
- package/README.md +236 -2
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/bin/aura-mcp.mjs +566 -0
- package/bin/aura.mjs +616 -0
- package/lib/fs-provider.mjs +355 -0
- package/lib/observation-store.mjs +369 -0
- package/lib/ops.mjs +662 -0
- package/lib/transaction-store.mjs +350 -0
- package/lib/workspace-lock.mjs +151 -0
- package/locator-lab/anchor-core.mjs +586 -0
- package/locator-lab/args.mjs +588 -0
- package/locator-lab/handle-core.mjs +207 -0
- package/locator-lab/locator-api.mjs +1159 -0
- package/locator-lab/read-core.mjs +3084 -0
- package/locator-lab/repo-map.mjs +768 -0
- package/locator-lab/struct-core.mjs +3084 -0
- package/locator-lab/write-path.mjs +3995 -0
- package/package.json +50 -4
- package/test/argument-surface-test.mjs +119 -0
- package/test/doctor-test.mjs +73 -0
- package/test/observation-concurrency-test.mjs +90 -0
- package/test/standalone-test.mjs +1649 -0
- package/test/write-test.mjs +294 -0
package/bin/aura-mcp.mjs
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aura MCP 服务(stdio,JSON-RPC 2.0,一行一条消息)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么是它:Codex / Claude Code / Cursor / Zed 这些 agent 都吃 MCP —— 一次写壳,到处能用,
|
|
6
|
+
* 且**不要求对方装 DSH**(本文件只依赖 node:fs 与本包的引擎)。
|
|
7
|
+
*
|
|
8
|
+
* 暴露五个工具:
|
|
9
|
+
* aura_basetools_read 七种取景(map/digest/full/around/lines/search/changed)+ 版本令牌
|
|
10
|
+
* aura_basetools_edit 四层寻址 + expect + dry_run;逐字节写回 + 陈旧区域闸门 + 落盘后回读校验
|
|
11
|
+
* aura_basetools_write 整份写入(对应内置 Write):新建或整文件替换,同走事务与回读校验
|
|
12
|
+
* aura_basetools_undo 撤销最近一次/指定写事务(默认拒绝覆盖后续编辑)
|
|
13
|
+
* aura_basetools_transactions 列出当前工作区写事务
|
|
14
|
+
*
|
|
15
|
+
* 接法(写进各家的 MCP 配置即可):
|
|
16
|
+
* codex: codex mcp add aura -- npx -y --package aura-basetools aura mcp
|
|
17
|
+
* claude code: claude mcp add aura -- npx -y --package aura-basetools aura mcp
|
|
18
|
+
* cursor/zed: command = "npx", args = ["-y", "--package", "aura-basetools", "aura", "mcp"]
|
|
19
|
+
*
|
|
20
|
+
* 为什么必须带 `--package`:本包有两个 bin(`aura` / `aura-mcp`),**没有**一个叫
|
|
21
|
+
* `aura-basetools`;npx 在"多个 bin 且没有与包名同名的"时候会拒绝猜,直接报
|
|
22
|
+
* `could not determine executable to run`(实测)。`--package` 明确指定包,`aura` 才是命令。
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from 'node:fs'
|
|
25
|
+
import process from 'node:process'
|
|
26
|
+
import { pathToFileURL } from 'node:url'
|
|
27
|
+
import {
|
|
28
|
+
openProvider,
|
|
29
|
+
readEnvelope,
|
|
30
|
+
editEnvelope,
|
|
31
|
+
writeEnvelope,
|
|
32
|
+
// 多目标(fs-contract-v2)的引擎绑定:与单目标同一处(`lib/ops.mjs`),bin 里不另找引擎。
|
|
33
|
+
readManyWith,
|
|
34
|
+
editManyEnvelope,
|
|
35
|
+
undoEnvelope,
|
|
36
|
+
transactionsEnvelope,
|
|
37
|
+
assertReadTargets,
|
|
38
|
+
assertEditTargets,
|
|
39
|
+
assertTargetShape,
|
|
40
|
+
assertKnownArgs,
|
|
41
|
+
MT_READ_ELEMENT_KEYS,
|
|
42
|
+
MT_EDIT_ELEMENT_KEYS,
|
|
43
|
+
} from '../lib/ops.mjs'
|
|
44
|
+
|
|
45
|
+
// 版本从包自己的 package.json 读:写死过一次,结果 0.4.0 的服务对外还报 0.2.1。
|
|
46
|
+
// 取不到(开发树里没有包清单)就报 dev,不谎报版本号。
|
|
47
|
+
const VERSION = (() => {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
50
|
+
} catch {
|
|
51
|
+
return 'dev'
|
|
52
|
+
}
|
|
53
|
+
})()
|
|
54
|
+
const DEFAULT_PROTOCOL = '2025-06-18'
|
|
55
|
+
/**
|
|
56
|
+
* 服务端真正支持的 MCP 协议版本(按新到旧)。
|
|
57
|
+
*
|
|
58
|
+
* 旧实现原样回显客户端给的任意字符串:客户端发 `9999-99-99`,服务端就宣称自己支持
|
|
59
|
+
* 一个不存在的版本。按 MCP 规范:受支持的请求版本原样返回;不受支持的返回服务端最新的
|
|
60
|
+
* 受支持版本;类型不合法是参数错误(`-32602`),不是“照抄”。
|
|
61
|
+
*/
|
|
62
|
+
const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05']
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 两个工具共用的 `targets` 声明(fs-contract-v2 §2.1/§2.2)。
|
|
66
|
+
*
|
|
67
|
+
* 一句话说清四件事:一次调用、逐项回执、输出按输入序、一个目标失败不连坐其余
|
|
68
|
+
* (写侧例外:先全部规划,任一规划失败 ⇒ 一个字都不写)。
|
|
69
|
+
* 元素内部**不在 schema 层细化**:读侧元素就是上面那套单目标参数、写侧元素就是 `{file_path, hunks}`,
|
|
70
|
+
* 判据由运行期那两份(`assertReadTargets` / `assertEditTargets`)给,与 DSH 壳一字不差。
|
|
71
|
+
*/
|
|
72
|
+
const TARGETS_SCHEMA = {
|
|
73
|
+
type: 'array',
|
|
74
|
+
items: { type: 'object' },
|
|
75
|
+
description:
|
|
76
|
+
'一次调用读/改多个目标(与 `file_path` 互斥,最多 32 项):每个元素就是上面那套单目标参数(读:file_path/mode/lines…;写:file_path/hunks/create)。' +
|
|
77
|
+
'一次调用、逐项回执、输出按输入序;一个目标失败不连坐其余(写侧例外:先全部规划,任一规划失败 ⇒ 一个字都不写)。',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const HUNK_SCHEMA = {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
target: { type: 'string', description: '符号名,如 `toTodoList` 或 `function:apply`;跳过全部文本匹配' },
|
|
84
|
+
intent: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description:
|
|
87
|
+
'模糊意图地址:符号名片段、短语或近似原文(如 `parseConfig`、`config loader`、`// retry the upload`),由引擎确定性地落成一个精确区间;歧义时报候选并拒绝(不猜)。单独使用(不与 target/lines/line_cue/anchor/old/context/handle 同用);可用 near 与 expect',
|
|
88
|
+
},
|
|
89
|
+
lines: {
|
|
90
|
+
oneOf: [
|
|
91
|
+
{ type: 'array' },
|
|
92
|
+
{ type: 'string' },
|
|
93
|
+
{ type: 'integer' },
|
|
94
|
+
{ type: 'object', additionalProperties: true },
|
|
95
|
+
],
|
|
96
|
+
description: '行号区间 [起, 止](1-based,闭区间);也接受 "4,6"、单个 4 或 {start,end}。推荐改用 start_line + end_line',
|
|
97
|
+
},
|
|
98
|
+
start_line: { type: 'integer', description: '推荐:区间起始行(1-based,闭区间)' },
|
|
99
|
+
end_line: { type: 'integer', description: '推荐:区间结束行(1-based,闭区间);省略则等于 start_line' },
|
|
100
|
+
line_cue: { type: 'string', description: '该行上唯一的词' },
|
|
101
|
+
anchor: { type: 'string', description: '记得的原文,容忍空白漂移与改写' },
|
|
102
|
+
old: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
description:
|
|
105
|
+
'内容地址:你正在替换的**逐字原文**(+ 可选 context 消歧)。给了它,坐标/符号名降为提示;引擎按内容定位,不唯一时拒绝并给候选。',
|
|
106
|
+
},
|
|
107
|
+
context: { type: 'string', description: '与 old 同用:紧邻被替换区域的原文,只在 old 多处命中时用来消歧。' },
|
|
108
|
+
/**
|
|
109
|
+
* fs-contract-v2 §5:读侧发的**区间句柄**(`aura_basetools_read` 的回执里就有,也在正文首行行尾)。
|
|
110
|
+
*
|
|
111
|
+
* 为什么要声明:句柄把「被编辑的那段字节」从模型手里拿走,换成工具自己账本里那份逐字节副本的
|
|
112
|
+
* 凭据;而**参数表里没有的键在这一面就等于不存在** —— CLI 读出来的回执带着柄,模型却看不见
|
|
113
|
+
* 这条路。这一面只做「收得下 + 原样递到引擎」两件事,判据(形状、过期、身份标签、相对定位)
|
|
114
|
+
* 全在引擎(`locator-lab/handle-core.mjs` + `write-path.mjs`),与 DSH 壳那三处**同一套事实**。
|
|
115
|
+
*/
|
|
116
|
+
handle: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
description:
|
|
119
|
+
'`aura_basetools_read` 发的区间句柄(不透明,如 `h9f3ac1b2e4d5:120-160`):工具读过的**逐字节区间**,于是那段文字不必由你转述。配合 `line` 或 `position` 在区间内/边界定位。句柄优先于 `old`/坐标——被让开的一方会在回执里点名。',
|
|
120
|
+
},
|
|
121
|
+
/** 段内**相对**行:整段替换与“只换其中一行”由此分开;越界是**结果**(正常 not_found 回执),不是抛错。 */
|
|
122
|
+
line: {
|
|
123
|
+
type: 'integer',
|
|
124
|
+
description:
|
|
125
|
+
'与 `handle` 同用:区间**内**第 k 行(1-based,从区间首行数起)——只换这一行,不必重述整段。越界走正常的 not_found 回执(`PATCH_HANDLE_LINE_OUT_OF_RANGE`)。单独给(没有 `handle`)不构成地址。',
|
|
126
|
+
},
|
|
127
|
+
/** 区间**边界**插入:不改动区间本身;“在这段里面”由 `line`/`old` 表达。 */
|
|
128
|
+
position: {
|
|
129
|
+
type: 'string',
|
|
130
|
+
enum: ['before', 'after'],
|
|
131
|
+
description:
|
|
132
|
+
'与 `handle` 同用:把替换文本插在区间**边界**(`before` / `after`)而不是替换区间本身——区间一个字节都不动。单独给(没有 `handle`)不构成地址。',
|
|
133
|
+
},
|
|
134
|
+
near: { type: 'integer', description: '歧义时的行号提示' },
|
|
135
|
+
expect: { type: 'string', description: '断言该处应是什么;对不上则整个补丁失败' },
|
|
136
|
+
replace: { type: 'string', description: '替换文本;空串 = 删除该区域' },
|
|
137
|
+
replace_all: {
|
|
138
|
+
type: 'boolean',
|
|
139
|
+
description:
|
|
140
|
+
'与 old 同用:替换全部非重叠出现(从左到右),不再要求唯一。不能与 target/intent/lines/line_cue/anchor/context/handle/near 同用;读取后出现次数变了会整体拒绝。',
|
|
141
|
+
},
|
|
142
|
+
eol: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
enum: ['auto', 'preserve', 'lf', 'crlf'],
|
|
145
|
+
description: '替换文本的换行风格。auto(默认)= 适配被替换区域的行尾(CRLF 文件写 CRLF,避免混合行尾);preserve = 按 replace 原样;lf/crlf = 强制。',
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
// `required` 只写 `replace`:`handle`/`line`/`position` 是**地址的可选形态**,写进必填会让
|
|
149
|
+
// 「按符号改一行」这类调用在参数层就被拒(与 DSH 壳那三处同一条:它们不是必填)。
|
|
150
|
+
required: ['replace'],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const TOOLS = [
|
|
154
|
+
{
|
|
155
|
+
name: 'aura_basetools_read',
|
|
156
|
+
description:
|
|
157
|
+
'Read a file the way you actually need it. Modes: map (symbol directory), digest (interface only), full, around, lines, search, changed. `auto` picks one for you. Prefer this over reading the whole file.',
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
file_path: { type: 'string', description: '要读的文件路径(绝对或相对 cwd)' },
|
|
162
|
+
mode: {
|
|
163
|
+
type: 'string',
|
|
164
|
+
enum: ['map', 'digest', 'full', 'around', 'lines', 'search', 'changed'],
|
|
165
|
+
description: '省略时按你给的参数自动判断:行区间→lines、around→around、search→search、since→changed',
|
|
166
|
+
},
|
|
167
|
+
around: { type: 'string', description: 'mode=around:以该符号或短语为中心开窗' },
|
|
168
|
+
lines: {
|
|
169
|
+
oneOf: [
|
|
170
|
+
{ type: 'array' },
|
|
171
|
+
{ type: 'string' },
|
|
172
|
+
{ type: 'integer' },
|
|
173
|
+
{ type: 'object', additionalProperties: true },
|
|
174
|
+
],
|
|
175
|
+
description: 'mode=lines:[起, 止];也接受 "412,500"、单个 412 或 {start,end}。推荐改用 start_line + end_line',
|
|
176
|
+
},
|
|
177
|
+
start_line: { type: 'integer', description: 'mode=lines(推荐):起始行,1-based,闭区间' },
|
|
178
|
+
end_line: { type: 'integer', description: 'mode=lines(推荐):结束行,1-based,闭区间;省略则等于 start_line' },
|
|
179
|
+
search: { type: 'string', description: 'mode=search:子串' },
|
|
180
|
+
context: { type: 'integer', description: 'mode=around:上下各留几行(默认 8)' },
|
|
181
|
+
kind: { type: 'string', description: 'mode=map:只保留某一类符号(function / class / …)' },
|
|
182
|
+
budget: { type: 'integer', description: '宿主剪枝阈值(字符);省略用默认 8192' },
|
|
183
|
+
since: { type: 'string', description: 'mode=changed:上次回执里的 version 令牌' },
|
|
184
|
+
max_chars: { type: 'integer' },
|
|
185
|
+
offset: { type: 'integer', description: '与 limit 同用:0-based 行偏移(内置 Read 的分页形状);与 lines 互斥' },
|
|
186
|
+
limit: { type: 'integer', description: '与 offset 同用:返回行数(默认 2000);与 lines 互斥' },
|
|
187
|
+
targets: TARGETS_SCHEMA,
|
|
188
|
+
},
|
|
189
|
+
/**
|
|
190
|
+
* `required` 里**不写** `file_path`:单目标(`file_path`)与多目标(`targets`)必居其一,
|
|
191
|
+
* 而“二选一”在 schema 层表达不了(本框架方言不支持 `anyOf`)—— 硬写必填会让多目标形状
|
|
192
|
+
* 在**到达服务之前**就被客户端拒掉。判据只有一处:运行期 `assertTargetShape`(两者都没给)
|
|
193
|
+
* 与 `assertReadTargets`(形状与互斥),与两个 DSH 壳、CLI 共用同一份。
|
|
194
|
+
* 与下面 `aura_basetools_edit` 里 `hunks` 不在 required 是同一条理由。
|
|
195
|
+
*/
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'aura_basetools_edit',
|
|
200
|
+
description:
|
|
201
|
+
'Edit or create a UTF-8 text file with hunks in one call. Address each hunk by intent (fuzzy name/phrase), target (symbol), lines, line_cue, anchor, or old (verbatim content + optional context). With `old`, set `replace_all: true` to replace every non-overlapping occurrence. Writes back byte-for-byte (BOM and per-line endings preserved), refuses a hunk whose addressed region changed since your last read, and reads the file back to verify. All hunks must locate or nothing is written.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
file_path: { type: 'string' },
|
|
206
|
+
hunks: { type: 'array', items: HUNK_SCHEMA },
|
|
207
|
+
dry_run: { type: 'boolean', description: '只定位不落盘' },
|
|
208
|
+
include_transaction: { type: 'boolean', description: '在回执里返回 transaction id(默认不返回,保持输出稳定)' },
|
|
209
|
+
require_observation: {
|
|
210
|
+
type: 'boolean',
|
|
211
|
+
description: '严格新鲜度:要求这一版文件在本会话里被读过(或由本工具写过);否则 PATCH_UNOBSERVED。create 不受影响;dry_run 同样执行这条判决。',
|
|
212
|
+
},
|
|
213
|
+
create: { type: 'string', description: '文件不存在时用它创建(整份内容)' },
|
|
214
|
+
targets: TARGETS_SCHEMA,
|
|
215
|
+
},
|
|
216
|
+
/**
|
|
217
|
+
* `hunks` **不在 required 里**:`create` 就是在建一个新文件,那时没有 hunk 可给。
|
|
218
|
+
*
|
|
219
|
+
* 旧版把它写成必填,于是客户端在**到达下面那句友好提示之前**就被 JSON Schema 拒了——
|
|
220
|
+
* 而 `callTool` 里那句“hunks 必须是数组(即使是空数组也要给)”本来就是为了引导这个情况。
|
|
221
|
+
* 同一个坑 DSH 壳早就修过(`aura-edit/index.js`:不在 schema 层声明 hunks 必选,
|
|
222
|
+
* 因为“schema 层表达不了‘二选一’,硬写必填会让创建功能被自己的 schema 挡住”)。
|
|
223
|
+
* MCP 面只是把同一件事补上:两者真正的约束是“**hunks 或 create 至少一个**”,由运行期判。
|
|
224
|
+
*
|
|
225
|
+
* `required` 里同样**不写** `file_path`:单目标(`file_path`)与多目标(`targets`)必居其一,
|
|
226
|
+
* 判据只有一处(运行期 `assertTargetShape` ↔ `assertEditTargets`,与 DSH 壳共用)。
|
|
227
|
+
*/
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: 'aura_basetools_write',
|
|
232
|
+
description:
|
|
233
|
+
'Write a whole UTF-8 text file at once (create or overwrite), matching a plain Write tool. Preserves an existing file\'s BOM and line endings, goes through the same transaction/undo path as aura_basetools_edit, and reads the file back to verify bytes. Prefer aura_basetools_edit for partial changes.',
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: 'object',
|
|
236
|
+
properties: {
|
|
237
|
+
file_path: { type: 'string', description: '要写的文件路径(绝对或相对 cwd);不存在则创建' },
|
|
238
|
+
content: { type: 'string', description: '整份内容;覆盖已有文件时替换全部行' },
|
|
239
|
+
dry_run: { type: 'boolean', description: '只规划不落盘:新建报 create,覆盖校验换行与字节数' },
|
|
240
|
+
no_overwrite: { type: 'boolean', description: '文件已存在时拒绝(WRITE_EXISTS)' },
|
|
241
|
+
allow_unobserved: {
|
|
242
|
+
type: 'boolean',
|
|
243
|
+
description: '覆写默认要求先读过这一版(PATCH_UNOBSERVED);设为 true 才允许盲写,回执照实标 unverified_region。',
|
|
244
|
+
},
|
|
245
|
+
eol: {
|
|
246
|
+
type: 'string',
|
|
247
|
+
enum: ['auto', 'preserve', 'lf', 'crlf'],
|
|
248
|
+
description: '覆写时的换行风格:auto(默认)适配文件已有风格;preserve 按 content 原样写;lf/crlf 强制。新建文件始终按 content 原样写(非 auto 会被拒绝)。',
|
|
249
|
+
},
|
|
250
|
+
include_transaction: { type: 'boolean', description: '在回执里返回 transaction id(默认不返回)' },
|
|
251
|
+
},
|
|
252
|
+
required: ['file_path', 'content'],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: 'aura_basetools_undo',
|
|
257
|
+
description:
|
|
258
|
+
'Undo the latest committed write transaction, or the transaction with the given id. Refuses if a file changed after the transaction; pass force=true to override. Restores the exact bytes captured before the write.',
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
id: { type: 'string', description: '事务 id;省略则撤销最近一条 committed 事务' },
|
|
263
|
+
force: { type: 'boolean', description: '即使文件在事务后又被改过也强制恢复' },
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: 'aura_basetools_transactions',
|
|
269
|
+
description: 'List write transactions for the current workspace (id, status, file count, time).',
|
|
270
|
+
inputSchema: { type: 'object', properties: {} },
|
|
271
|
+
},
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 每个工具接受的顶层键,**直接从注册面同一份 `inputSchema` 派生**。
|
|
276
|
+
*
|
|
277
|
+
* 不再手抄第二份名单:schema 加了字段而这里漏改,就会把合法调用拒掉;反过来则把拼错的键
|
|
278
|
+
* 静默忽略(`dryrun: true` 会变成一次真写盘)。两种漂移都在这一行上消失。
|
|
279
|
+
*/
|
|
280
|
+
const ALLOWED_BY_TOOL = new Map(
|
|
281
|
+
TOOLS.map((tool) => [tool.name, Object.keys(tool.inputSchema?.properties ?? {})]),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
/** 未知键拒绝:走 normal envelope(`isError:true`),不让它变成写盘或挂死。 */
|
|
285
|
+
function unknownArgsRefusal(source, allowed, where) {
|
|
286
|
+
try {
|
|
287
|
+
assertKnownArgs(source, allowed, where)
|
|
288
|
+
return undefined
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
error: String(error?.message ?? error),
|
|
293
|
+
code: typeof error?.code === 'string' ? error.code : 'BAD_ARGS',
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* MCP 客户端的 `arguments` 按规范是对象;实测有的客户端会把 JSON 当**字符串**传过来(模型
|
|
300
|
+
* 把参数写坏时尤其常见),也有传数组的。字符串能解析就用,解不了就回一条可读的结构化错误——
|
|
301
|
+
* 绝不能让它变成 provider 内部的 TypeError(那种不给回包,客户端会一直等到自己的超时)。
|
|
302
|
+
*/
|
|
303
|
+
function toolArguments(raw) {
|
|
304
|
+
if (raw === undefined || raw === null) return {}
|
|
305
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) return raw
|
|
306
|
+
if (typeof raw === 'string') {
|
|
307
|
+
try {
|
|
308
|
+
const parsed = JSON.parse(raw)
|
|
309
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
|
|
310
|
+
} catch {
|
|
311
|
+
/* 落到下面那条统一错误 */
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return undefined
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 多目标读(MCP 面):`targets` 在场 ⇒ 引擎的批入口 `readManyWith`。
|
|
319
|
+
*
|
|
320
|
+
* 形状判据**一条都不在这里**:`assertReadTargets`(与写侧 `assertEditTargets` 是同一个判据,
|
|
321
|
+
* 只是提示语不同)住在 `locator-lab/args.mjs`,两个 DSH 壳与 CLI 共用同一份。
|
|
322
|
+
* 它抛的是**参数面**错(`ARGS_TARGETS_SHAPE` / `ARGS_TARGETS_LIMIT` / `ARGS_SHAPE_EXCLUSIVE`),
|
|
323
|
+
* 这里把它接成回包 —— 这一面从不把抛出交给客户端(等不到回包就是挂死,见 `serve` 里那条兜底)。
|
|
324
|
+
*
|
|
325
|
+
* 返回值就是**引擎那份批回执**(逐项 + 汇总行 `summary`,原样 JSON):一个目标失败不连坐其余,
|
|
326
|
+
* 整批 `ok` = 引擎自己的 `counts.failed === 0`。
|
|
327
|
+
*/
|
|
328
|
+
async function readTargetsCall(provider, args) {
|
|
329
|
+
let targets
|
|
330
|
+
try {
|
|
331
|
+
targets = assertReadTargets(args, { toolName: 'aura_basetools_read', elementKeys: MT_READ_ELEMENT_KEYS })
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return { ok: false, error: String(error?.message ?? error), code: typeof error?.code === 'string' ? error.code : 'BAD_ARGS' }
|
|
334
|
+
}
|
|
335
|
+
return await readManyWith(provider, targets)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 多目标写(MCP 面):`targets` ⇒ `editManyEnvelope`(引擎三阶段提交 + 独立面那个 `ok` 位)。
|
|
340
|
+
*
|
|
341
|
+
* `dry_run` 是这一面的旗标名(单目标那条路就是它)。元素自带 `hunks` / `create`,因此
|
|
342
|
+
* “hunks 或 create 至少一个”那条规则对多目标形状**不适用**。
|
|
343
|
+
*/
|
|
344
|
+
async function editTargetsCall(provider, args) {
|
|
345
|
+
let targets
|
|
346
|
+
try {
|
|
347
|
+
targets = assertEditTargets(args, { toolName: 'aura_basetools_edit', elementKeys: MT_EDIT_ELEMENT_KEYS })
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return { ok: false, error: String(error?.message ?? error), code: typeof error?.code === 'string' ? error.code : 'BAD_ARGS' }
|
|
350
|
+
}
|
|
351
|
+
return await editManyEnvelope(provider, {
|
|
352
|
+
targets,
|
|
353
|
+
dryRun: args.dry_run === true,
|
|
354
|
+
includeTransaction: args.include_transaction === true,
|
|
355
|
+
...(args.require_observation === true ? { requireObservation: true } : {}),
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 两种形状**都没给**(`file_path` 缺席或空串,且没有 `targets`)⇒ 参数面拒绝。
|
|
361
|
+
*
|
|
362
|
+
* 判据本体是引擎的 `assertTargetShape`(“必居其一”只有那一份);这里只把码换成这一面的
|
|
363
|
+
* `BAD_ARGS`(既有行为不变),并把**两种形状**写进错误里 —— 旧版那句只说 `file_path`,
|
|
364
|
+
* 于是“你其实该用 targets”这件事在回执里根本看不到。
|
|
365
|
+
*/
|
|
366
|
+
function noShapeRefusal(source, toolName) {
|
|
367
|
+
try {
|
|
368
|
+
assertTargetShape(source, { toolName })
|
|
369
|
+
} catch (error) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
error: `${String(error?.message ?? error)} — MCP 参数面:单目标是 \`file_path\`(非空字符串),一次多个目标是 \`targets\` 数组(每个元素就是那套单目标参数)`,
|
|
373
|
+
code: 'BAD_ARGS',
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return undefined
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function callTool(provider, name, args = {}) {
|
|
380
|
+
// 参数名错是最常见的 agent 失误(比如把 file_path 写成 file)。
|
|
381
|
+
// 这里先给一句人话,不让它退化成 provider 内部的 TypeError(那个不给回包,客户端会挂死)。
|
|
382
|
+
// 多目标形状(fs-contract-v2 §2.1/§2.2):`targets` 在场 ⇒ 走引擎的批入口;
|
|
383
|
+
// 此时 `file_path` **可以缺席**(“file_path 与 targets 二者至少一个,都缺才拒”)。
|
|
384
|
+
const multi = args.targets !== undefined && args.targets !== null
|
|
385
|
+
/**
|
|
386
|
+
* 未知参数在**任何工具分支之前**拒:旧版 `dryrun:true` 会一路走到写盘,因为这里只读
|
|
387
|
+
* `args.dry_run`、对多出来的键零反馈。schema 与这份名单同源(见 `ALLOWED_BY_TOOL`)。
|
|
388
|
+
* `targets` 元素也逐个校验:它们被声明成 `additionalProperties:true`(形状由运行期判),
|
|
389
|
+
* 但“运行期判形状”不等于“任意拼错的键都可以被静默丢掉”。
|
|
390
|
+
*/
|
|
391
|
+
{
|
|
392
|
+
const allowed = ALLOWED_BY_TOOL.get(name)
|
|
393
|
+
if (allowed !== undefined) {
|
|
394
|
+
const refusal = unknownArgsRefusal(args, allowed, name)
|
|
395
|
+
if (refusal !== undefined) return refusal
|
|
396
|
+
if (multi && Array.isArray(args.targets)) {
|
|
397
|
+
const elementAllowed = allowed.filter((key) => key !== 'targets')
|
|
398
|
+
for (const [index, element] of args.targets.entries()) {
|
|
399
|
+
if (element === null || typeof element !== 'object' || Array.isArray(element)) continue
|
|
400
|
+
const elementRefusal = unknownArgsRefusal(element, elementAllowed, `${name} targets[${index}]`)
|
|
401
|
+
if (elementRefusal !== undefined) return elementRefusal
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (name === 'aura_basetools_read') {
|
|
407
|
+
if (multi) return await readTargetsCall(provider, args)
|
|
408
|
+
const noShape = noShapeRefusal(args, 'aura_basetools_read')
|
|
409
|
+
if (noShape !== undefined) return noShape
|
|
410
|
+
// 原样透传(不再自己挑字段):行区间的宽容解析在 lib/ops.mjs → locator-lab/args.mjs 一处做,
|
|
411
|
+
// 三个调用面共用同一套判据。之前这里只挑了几个字段,start_line/end_line 会被默默丢掉。
|
|
412
|
+
return readEnvelope(provider, { ...args, file: args.file_path, maxChars: args.max_chars })
|
|
413
|
+
}
|
|
414
|
+
if (name === 'aura_basetools_edit') {
|
|
415
|
+
if (multi) return await editTargetsCall(provider, args)
|
|
416
|
+
const noShape = noShapeRefusal(args, 'aura_basetools_edit')
|
|
417
|
+
if (noShape !== undefined) return noShape
|
|
418
|
+
/**
|
|
419
|
+
* 真正的约束是“**hunks 或 create 至少一个**”(与 schema 的 `required` 一致)。
|
|
420
|
+
*
|
|
421
|
+
* 旧版无条件要求 hunks,于是 `create` 路径被自己的校验挡住,
|
|
422
|
+
* 而那句提示(“即使是空数组也要给”)本意是引导用户去写 `hunks: []`——
|
|
423
|
+
* 但那本来就是 workaround,不该是必需写法。三面(DSH 壳 / CLI / MCP)现在同一条规则。
|
|
424
|
+
* 多目标形状不走这一条:每个 target 自带 hunks/create(契约 §2.2)。
|
|
425
|
+
*/
|
|
426
|
+
const creating = typeof args.create === 'string'
|
|
427
|
+
if (!Array.isArray(args.hunks) && !creating) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
error: 'aura_basetools_edit 需要 hunks 数组,或 create(整份内容建文件)二选一;只建文件时直接给 create 即可',
|
|
431
|
+
code: 'BAD_ARGS',
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return await editEnvelope(provider, {
|
|
435
|
+
file: args.file_path,
|
|
436
|
+
hunks: Array.isArray(args.hunks) ? args.hunks : [],
|
|
437
|
+
dryRun: args.dry_run === true,
|
|
438
|
+
create: creating ? args.create : undefined,
|
|
439
|
+
includeTransaction: args.include_transaction === true,
|
|
440
|
+
...(args.require_observation === true ? { requireObservation: true } : {}),
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
if (name === 'aura_basetools_write') {
|
|
444
|
+
return await writeEnvelope(provider, {
|
|
445
|
+
file: args.file_path,
|
|
446
|
+
content: args.content,
|
|
447
|
+
dryRun: args.dry_run === true,
|
|
448
|
+
noOverwrite: args.no_overwrite === true,
|
|
449
|
+
includeTransaction: args.include_transaction === true,
|
|
450
|
+
allowUnobserved: args.allow_unobserved === true,
|
|
451
|
+
...(typeof args.eol === 'string' ? { eol: args.eol } : {}),
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
if (name === 'aura_basetools_undo') return await undoEnvelope(provider, { id: args.id, force: args.force === true })
|
|
455
|
+
if (name === 'aura_basetools_transactions') return transactionsEnvelope(provider)
|
|
456
|
+
return { ok: false, error: `unknown tool ${JSON.stringify(name)}`, code: 'UNKNOWN_TOOL' }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function serve() {
|
|
460
|
+
const provider = openProvider()
|
|
461
|
+
let buffer = ''
|
|
462
|
+
let chain = Promise.resolve()
|
|
463
|
+
|
|
464
|
+
const send = (message) => {
|
|
465
|
+
process.stdout.write(`${JSON.stringify(message)}\n`)
|
|
466
|
+
}
|
|
467
|
+
const reply = (id, result) => send({ jsonrpc: '2.0', id, result })
|
|
468
|
+
const replyError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } })
|
|
469
|
+
|
|
470
|
+
const handle = async (line) => {
|
|
471
|
+
let msg
|
|
472
|
+
try {
|
|
473
|
+
msg = JSON.parse(line)
|
|
474
|
+
} catch {
|
|
475
|
+
return replyError(null, -32700, 'Parse error')
|
|
476
|
+
}
|
|
477
|
+
const { id, method, params } = msg
|
|
478
|
+
const isNotification = id === undefined || id === null
|
|
479
|
+
try {
|
|
480
|
+
if (method === 'initialize') {
|
|
481
|
+
const asked = params?.protocolVersion
|
|
482
|
+
if (asked !== undefined && asked !== null && (typeof asked !== 'string' || asked.length === 0)) {
|
|
483
|
+
return replyError(
|
|
484
|
+
id,
|
|
485
|
+
-32602,
|
|
486
|
+
`protocolVersion must be a non-empty string; supported: ${SUPPORTED_PROTOCOLS.join(', ')}`,
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
const version =
|
|
490
|
+
typeof asked === 'string' && SUPPORTED_PROTOCOLS.includes(asked)
|
|
491
|
+
? asked
|
|
492
|
+
: DEFAULT_PROTOCOL
|
|
493
|
+
return reply(id, {
|
|
494
|
+
protocolVersion: version,
|
|
495
|
+
supportedProtocolVersions: SUPPORTED_PROTOCOLS,
|
|
496
|
+
capabilities: { tools: { listChanged: false } },
|
|
497
|
+
serverInfo: { name: 'aura-basetools', version: VERSION },
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
if (method === 'ping') return reply(id, {})
|
|
501
|
+
if (method === 'tools/list') return reply(id, { tools: TOOLS })
|
|
502
|
+
if (method === 'tools/call') {
|
|
503
|
+
let env
|
|
504
|
+
const callArgs = toolArguments(params?.arguments)
|
|
505
|
+
if (callArgs === undefined) {
|
|
506
|
+
return reply(id, {
|
|
507
|
+
content: [
|
|
508
|
+
{
|
|
509
|
+
type: 'text',
|
|
510
|
+
text: JSON.stringify({
|
|
511
|
+
ok: false,
|
|
512
|
+
code: 'BAD_ARGS',
|
|
513
|
+
error: 'arguments 必须是 JSON 对象(收到字符串或数组,且不是可解析的 JSON 对象)',
|
|
514
|
+
}),
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
isError: true,
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
env = await callTool(provider, params?.name, callArgs)
|
|
522
|
+
} catch (error) {
|
|
523
|
+
// 兜底:漏网的抛出也必须变成回包。不回包 = 客户端永久等待(实测过一轮:
|
|
524
|
+
// 参数名写错 → 只有一行 stderr,客户端挂到自己的超时为止)。
|
|
525
|
+
process.stderr.write(`aura-mcp: tools/call 抛出:${String(error?.stack ?? error)}\n`)
|
|
526
|
+
env = { ok: false, error: String(error?.message ?? error), code: error?.code ?? 'TOOL_THREW' }
|
|
527
|
+
}
|
|
528
|
+
return reply(id, {
|
|
529
|
+
content: [{ type: 'text', text: JSON.stringify(env) }],
|
|
530
|
+
isError: env.ok !== true,
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
if (isNotification) return undefined // 通知不回包(notifications/initialized、cancelled…)
|
|
534
|
+
return replyError(id, -32601, `Method not found: ${String(method)}`)
|
|
535
|
+
} catch (error) {
|
|
536
|
+
process.stderr.write(`aura-mcp: 处理抛出:${String(error?.stack ?? error)}\n`)
|
|
537
|
+
if (isNotification) return undefined
|
|
538
|
+
return replyError(id, -32603, `内部错误:${String(error?.message ?? error)}`)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
process.stdin.setEncoding('utf8')
|
|
543
|
+
process.stdin.on('data', (chunk) => {
|
|
544
|
+
buffer += chunk
|
|
545
|
+
let index
|
|
546
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
547
|
+
const line = buffer.slice(0, index).trim()
|
|
548
|
+
buffer = buffer.slice(index + 1)
|
|
549
|
+
// 单帧失败不能让服务停摆,但**绝不能吞掉**(这里原本写的是 `.catch(() => undefined)`,
|
|
550
|
+
// 结果 MCP 一声不响、定位花了一轮):错误写 stderr,回包给结构化错误。
|
|
551
|
+
chain = chain
|
|
552
|
+
.then(() => handle(line))
|
|
553
|
+
.catch((error) => {
|
|
554
|
+
process.stderr.write(`aura-mcp: ${String(error?.stack ?? error)}\n`)
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
await new Promise((resolve) => process.stdin.on('end', resolve))
|
|
559
|
+
await chain
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// 直接运行本文件(`aura-mcp`,package.json 里声明的第二个 bin)也要起服务,与 `aura mcp` 等价。
|
|
563
|
+
// 用 pathToFileURL 比手拼 file:// 靠得住(Windows 盘符就是那种手拼会踩的地方)。
|
|
564
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
565
|
+
await serve()
|
|
566
|
+
}
|