@autobest-ui/agent 1.0.2 → 1.0.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/mcp/rag-mcp-bridge/README.md +38 -1
- package/mcp/rag-mcp-bridge/codex-system-prompt.md +1 -1
- package/mcp/rag-mcp-bridge/documents.js +236 -0
- package/mcp/rag-mcp-bridge/index.js +12 -58
- package/mcp/rag-mcp-bridge/index.test.js +248 -21
- package/mcp/rag-mcp-bridge/package.json +2 -1
- package/package.json +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# rag-mcp-bridge
|
|
2
2
|
|
|
3
|
-
`rag-mcp-bridge` 是 PRD Knowledge RAG API 的本地 STDIO MCP
|
|
3
|
+
`rag-mcp-bridge` 是 PRD Knowledge RAG API 的本地 STDIO MCP 适配器。它在 MCP 客户端所在机器展开并读取 Markdown,把实际引用的本地图片打包到 `documents` 请求后转发给 HTTP API。因此 API 可以部署在另一台机器,不需要访问客户端的本地文件系统。Embedding、数据库读写和检索仍由 Express 服务完成。
|
|
4
4
|
|
|
5
5
|
## 启动
|
|
6
6
|
|
|
@@ -47,6 +47,43 @@ RAG_MCP_HTTP_TIMEOUT_MS = "120000"
|
|
|
47
47
|
- `update_spec_file`:录入已经合并并确认生效的迭代 Spec。
|
|
48
48
|
- `retrieve_knowledge`:检索 PRD 基线和迭代 Spec。
|
|
49
49
|
|
|
50
|
+
两个录入工具的 MCP 参数保持不变:`paths` 支持单个 Markdown 绝对路径、绝对路径数组或目录。bridge 递归展开目录,并向 API 发送以下协议:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"documents": [
|
|
55
|
+
{
|
|
56
|
+
"docKey": "/absolute/client/path/file.md",
|
|
57
|
+
"fileName": "file.md",
|
|
58
|
+
"markdown": "完整 Markdown 内容",
|
|
59
|
+
"assets": [
|
|
60
|
+
{
|
|
61
|
+
"path": "../.attachments/example.png",
|
|
62
|
+
"mimeType": "image/png",
|
|
63
|
+
"base64": "..."
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"platform": "app",
|
|
69
|
+
"module": "vehicle-selector",
|
|
70
|
+
"page": "home"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`add_prd_file` 发送到 `/add-prd`;`update_spec_file` 发送到 `/add-spec`,并额外保留 `sprintVersion`、`iterationTag`。多文档请求不发送 `page`,由后端按文件名分别推断;单文档请求继续透传显式 `page`。`retrieve_knowledge` 的 GET 协议不变。
|
|
75
|
+
|
|
76
|
+
## 图片安全边界
|
|
77
|
+
|
|
78
|
+
- 使用 Markdown AST 识别行内图片和引用式图片,不修改 Markdown 原文。
|
|
79
|
+
- URL 编码会在解析本地文件路径前解码,上传的 `assets[].path` 保留 Markdown 中的原引用。
|
|
80
|
+
- HTTP、HTTPS、data URL、其他带 scheme 的 URL、协议相对 URL 和站点根路径引用保持原样,不读取或上传。
|
|
81
|
+
- 本地图片仅支持 PNG、JPEG、GIF、WebP;扩展名必须与文件内容签名对应。
|
|
82
|
+
- 普通相对图片必须位于 Markdown 所在目录内;允许使用 `../.attachments/...` 引用紧邻的上级附件目录。词法路径和符号链接真实路径都会进行边界检查。
|
|
83
|
+
- 单张图片最大 `10 MiB`;包含 Markdown 和 base64 后的完整 JSON 请求最大 `40 MiB`。
|
|
84
|
+
- Markdown 引用的受支持本地图片缺失、类型非法、内容签名不符或路径越界时,整次调用失败并返回包含 Markdown 文件名和图片引用的错误。
|
|
85
|
+
- 同一 Markdown 内相同引用只上传一次;不同 Markdown 引用同一图片时,每个 document 都携带自己的 asset,保持文档协议自包含。
|
|
86
|
+
|
|
50
87
|
运行协议测试:
|
|
51
88
|
|
|
52
89
|
```bash
|
|
@@ -17,4 +17,4 @@
|
|
|
17
17
|
- `update_spec_file`:仅在PR已经合并且leader review确认变更生效后调用。必须传与基线一致的 `platform`;`paths` 同样支持单个spec文件、spec文件数组、spec文件夹;只传本次改动spec,不需要完整PRD。`sprintVersion` 必须按迭代递增。单文件spec可以显式传其 `page`。
|
|
18
18
|
- `retrieve_knowledge`:分析需求、制定实现方案、编写代码或判断历史行为前调用。必须根据目标端传 `platform`,已知模块时传 `module`,明确页面时传 `page`,已知文档时传 `docKey`。仅明确需要六字段结构化PRD时传 `enableStruct=true`;仅需要在UI显示需求截图时传 `includeImages=true`。
|
|
19
19
|
|
|
20
|
-
`rag-mcp-bridge`
|
|
20
|
+
`rag-mcp-bridge` 在MCP客户端所在机器展开路径、读取Markdown,并使用Markdown AST收集实际引用且通过安全校验的本地图片,然后通过 `documents` 协议把内容发送给API。远端API不需要访问客户端路径;Embedding、RAG与PostgreSQL操作仍由Express后端完成。
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
4
|
+
|
|
5
|
+
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
export const MAX_REQUEST_BYTES = 40 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
const IMAGE_TYPES = new Map([
|
|
9
|
+
['.gif', 'image/gif'],
|
|
10
|
+
['.jpeg', 'image/jpeg'],
|
|
11
|
+
['.jpg', 'image/jpeg'],
|
|
12
|
+
['.png', 'image/png'],
|
|
13
|
+
['.webp', 'image/webp']
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
async function scanMarkdownDirectory(directoryPath) {
|
|
17
|
+
const markdownFiles = [];
|
|
18
|
+
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
19
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
20
|
+
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const entryPath = path.join(directoryPath, entry.name);
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
markdownFiles.push(...(await scanMarkdownDirectory(entryPath)));
|
|
25
|
+
} else if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.md') {
|
|
26
|
+
markdownFiles.push(path.resolve(entryPath));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return markdownFiles;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function expandMarkdownPaths(pathsInput) {
|
|
33
|
+
const inputs = Array.isArray(pathsInput) ? pathsInput : [pathsInput];
|
|
34
|
+
const markdownFiles = [];
|
|
35
|
+
|
|
36
|
+
for (const input of inputs) {
|
|
37
|
+
if (typeof input !== 'string' || !input.trim()) {
|
|
38
|
+
throw new Error('paths中的每一项都必须是非空字符串');
|
|
39
|
+
}
|
|
40
|
+
if (!path.isAbsolute(input.trim())) {
|
|
41
|
+
throw new Error(`必须使用绝对路径: ${input}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const absolutePath = path.resolve(input.trim());
|
|
45
|
+
const pathStat = await stat(absolutePath);
|
|
46
|
+
if (pathStat.isDirectory()) {
|
|
47
|
+
markdownFiles.push(...(await scanMarkdownDirectory(absolutePath)));
|
|
48
|
+
} else if (pathStat.isFile() && path.extname(absolutePath).toLowerCase() === '.md') {
|
|
49
|
+
markdownFiles.push(absolutePath);
|
|
50
|
+
} else {
|
|
51
|
+
throw new Error(`路径不是Markdown文件或文件夹: ${absolutePath}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const uniquePaths = [...new Set(markdownFiles)].sort((left, right) => left.localeCompare(right));
|
|
56
|
+
if (uniquePaths.length === 0) {
|
|
57
|
+
throw new Error('指定路径中没有找到任何.md文件');
|
|
58
|
+
}
|
|
59
|
+
return uniquePaths;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function walk(node, visit) {
|
|
63
|
+
visit(node);
|
|
64
|
+
if (!Array.isArray(node.children)) return;
|
|
65
|
+
for (const child of node.children) walk(child, visit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extractImageReferences(markdown) {
|
|
69
|
+
const tree = fromMarkdown(markdown);
|
|
70
|
+
const definitions = new Map();
|
|
71
|
+
walk(tree, node => {
|
|
72
|
+
if (node.type === 'definition') definitions.set(node.identifier, node.url);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const references = [];
|
|
76
|
+
walk(tree, node => {
|
|
77
|
+
if (node.type === 'image') references.push(node.url);
|
|
78
|
+
if (node.type === 'imageReference') {
|
|
79
|
+
const definitionUrl = definitions.get(node.identifier);
|
|
80
|
+
if (definitionUrl) references.push(definitionUrl);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return [...new Set(references)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isNonLocalReference(reference) {
|
|
87
|
+
return (
|
|
88
|
+
reference.startsWith('#') ||
|
|
89
|
+
reference.startsWith('/') ||
|
|
90
|
+
reference.startsWith('//') ||
|
|
91
|
+
/^[a-z][a-z\d+.-]*:/i.test(reference)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function decodeLocalReference(reference, markdownPath) {
|
|
96
|
+
const pathOnly = reference.split(/[?#]/, 1)[0];
|
|
97
|
+
try {
|
|
98
|
+
const decoded = decodeURIComponent(pathOnly);
|
|
99
|
+
if (!decoded || decoded.includes('\0') || decoded.includes('\\')) {
|
|
100
|
+
throw new Error('图片路径为空或包含不支持的字符');
|
|
101
|
+
}
|
|
102
|
+
return decoded;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const reason = error instanceof URIError ? 'URL编码无效' : error.message;
|
|
105
|
+
throw new Error(`Markdown ${path.basename(markdownPath)} 的图片路径无效: ${reference} (${reason})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isPathInside(candidate, root) {
|
|
110
|
+
const relative = path.relative(root, candidate);
|
|
111
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function allowedAssetRoot(markdownPath, decodedReference) {
|
|
115
|
+
const markdownDirectory = path.dirname(markdownPath);
|
|
116
|
+
const segments = decodedReference.split('/');
|
|
117
|
+
if (segments[0] !== '..') return markdownDirectory;
|
|
118
|
+
if (segments[1] !== '.attachments') return undefined;
|
|
119
|
+
return path.resolve(markdownDirectory, '..', '.attachments');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function detectMimeType(buffer) {
|
|
123
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
124
|
+
return 'image/png';
|
|
125
|
+
}
|
|
126
|
+
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
127
|
+
return 'image/jpeg';
|
|
128
|
+
}
|
|
129
|
+
const prefix = buffer.subarray(0, 6).toString('ascii');
|
|
130
|
+
if (prefix === 'GIF87a' || prefix === 'GIF89a') return 'image/gif';
|
|
131
|
+
if (
|
|
132
|
+
buffer.length >= 12 &&
|
|
133
|
+
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
134
|
+
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
135
|
+
) {
|
|
136
|
+
return 'image/webp';
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readLocalAsset(markdownPath, reference) {
|
|
142
|
+
const decodedReference = decodeLocalReference(reference, markdownPath);
|
|
143
|
+
const extension = path.extname(decodedReference).toLowerCase();
|
|
144
|
+
const expectedMimeType = IMAGE_TYPES.get(extension);
|
|
145
|
+
if (!expectedMimeType) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Markdown ${path.basename(markdownPath)} 引用了不受支持的图片类型: ${reference}(仅支持 PNG、JPEG、GIF、WebP)`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const markdownDirectory = path.dirname(markdownPath);
|
|
152
|
+
const assetPath = path.resolve(markdownDirectory, decodedReference);
|
|
153
|
+
const allowedRoot = allowedAssetRoot(markdownPath, decodedReference);
|
|
154
|
+
if (!allowedRoot || !isPathInside(assetPath, allowedRoot)) {
|
|
155
|
+
throw new Error(`Markdown ${path.basename(markdownPath)} 的图片路径越界: ${reference}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let assetStat;
|
|
159
|
+
try {
|
|
160
|
+
assetStat = await stat(assetPath);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error?.code === 'ENOENT') {
|
|
163
|
+
throw new Error(`Markdown ${path.basename(markdownPath)} 引用的图片不存在: ${reference}`);
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
if (!assetStat.isFile()) {
|
|
168
|
+
throw new Error(`Markdown ${path.basename(markdownPath)} 引用的图片不是文件: ${reference}`);
|
|
169
|
+
}
|
|
170
|
+
if (assetStat.size > MAX_IMAGE_BYTES) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Markdown ${path.basename(markdownPath)} 引用的图片超过单文件限制 ${MAX_IMAGE_BYTES} bytes: ${reference}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const [realAssetPath, realAllowedRoot] = await Promise.all([realpath(assetPath), realpath(allowedRoot)]);
|
|
177
|
+
if (!isPathInside(realAssetPath, realAllowedRoot)) {
|
|
178
|
+
throw new Error(`Markdown ${path.basename(markdownPath)} 的图片路径通过符号链接越界: ${reference}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const buffer = await readFile(realAssetPath);
|
|
182
|
+
if (buffer.length > MAX_IMAGE_BYTES) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Markdown ${path.basename(markdownPath)} 引用的图片超过单文件限制 ${MAX_IMAGE_BYTES} bytes: ${reference}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const detectedMimeType = detectMimeType(buffer);
|
|
188
|
+
if (detectedMimeType !== expectedMimeType) {
|
|
189
|
+
throw new Error(`Markdown ${path.basename(markdownPath)} 引用的图片内容与扩展名不匹配: ${reference}`);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
path: reference,
|
|
193
|
+
mimeType: expectedMimeType,
|
|
194
|
+
base64: buffer.toString('base64')
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function buildDocuments(markdownPaths) {
|
|
199
|
+
const documents = [];
|
|
200
|
+
let contentBytes = 0;
|
|
201
|
+
for (const markdownPath of markdownPaths) {
|
|
202
|
+
const markdown = await readFile(markdownPath, 'utf8');
|
|
203
|
+
contentBytes += Buffer.byteLength(markdown);
|
|
204
|
+
if (contentBytes > MAX_REQUEST_BYTES) {
|
|
205
|
+
throw new Error(`documents内容超过单次请求限制 ${MAX_REQUEST_BYTES} bytes`);
|
|
206
|
+
}
|
|
207
|
+
const imageReferences = extractImageReferences(markdown);
|
|
208
|
+
const assets = [];
|
|
209
|
+
for (const reference of imageReferences) {
|
|
210
|
+
if (!reference || isNonLocalReference(reference)) continue;
|
|
211
|
+
const asset = await readLocalAsset(markdownPath, reference);
|
|
212
|
+
contentBytes +=
|
|
213
|
+
Buffer.byteLength(asset.path) + Buffer.byteLength(asset.mimeType) + Buffer.byteLength(asset.base64);
|
|
214
|
+
if (contentBytes > MAX_REQUEST_BYTES) {
|
|
215
|
+
throw new Error(`documents内容超过单次请求限制 ${MAX_REQUEST_BYTES} bytes`);
|
|
216
|
+
}
|
|
217
|
+
assets.push(asset);
|
|
218
|
+
}
|
|
219
|
+
documents.push({
|
|
220
|
+
docKey: path.resolve(markdownPath),
|
|
221
|
+
fileName: path.basename(markdownPath),
|
|
222
|
+
markdown,
|
|
223
|
+
assets
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return documents;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function serializeRequestBody(body) {
|
|
230
|
+
const serialized = JSON.stringify(body);
|
|
231
|
+
const requestBytes = Buffer.byteLength(serialized);
|
|
232
|
+
if (requestBytes > MAX_REQUEST_BYTES) {
|
|
233
|
+
throw new Error(`documents请求超过单次限制 ${MAX_REQUEST_BYTES} bytes(实际 ${requestBytes} bytes)`);
|
|
234
|
+
}
|
|
235
|
+
return serialized;
|
|
236
|
+
}
|
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
* RAG MCP bridge for the local PRD knowledge Express API.
|
|
5
5
|
*
|
|
6
6
|
* Responsibilities are deliberately limited to:
|
|
7
|
-
* 1.
|
|
8
|
-
* 2. Forwarding MCP tool calls to the
|
|
7
|
+
* 1. Packaging local Markdown files and referenced images for remote ingestion.
|
|
8
|
+
* 2. Forwarding MCP tool calls to the configured HTTP API.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Requirement parsing, database access, embeddings, and RAG stay in Express.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
14
|
-
import path from 'node:path';
|
|
15
13
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
16
14
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
15
|
import { z } from 'zod';
|
|
16
|
+
import { buildDocuments, expandMarkdownPaths, serializeRequestBody } from './documents.js';
|
|
18
17
|
|
|
19
18
|
const DEFAULT_HTTP_TIMEOUT_MS = 120_000;
|
|
20
19
|
|
|
@@ -62,60 +61,13 @@ const pageSchema = z
|
|
|
62
61
|
.describe('业务页面键,例如home、pl、pn、pd、name-search;录入多个文件时不要传,由后端按文件名分别推断');
|
|
63
62
|
const docKeySchema = z.string().min(1).optional().describe('需要限定到单一文档时传knowledge_doc.doc_key绝对路径');
|
|
64
63
|
|
|
65
|
-
/** Recursively collects Markdown files from a directory in stable order. */
|
|
66
|
-
async function scanMarkdownDirectory(directoryPath) {
|
|
67
|
-
const markdownFiles = [];
|
|
68
|
-
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
69
|
-
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
70
|
-
|
|
71
|
-
for (const entry of entries) {
|
|
72
|
-
const entryPath = path.join(directoryPath, entry.name);
|
|
73
|
-
if (entry.isDirectory()) {
|
|
74
|
-
markdownFiles.push(...(await scanMarkdownDirectory(entryPath)));
|
|
75
|
-
} else if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.md') {
|
|
76
|
-
markdownFiles.push(path.resolve(entryPath));
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return markdownFiles;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Normalizes all supported path inputs to a unique array of absolute .md files. */
|
|
83
|
-
async function expandMarkdownPaths(pathsInput) {
|
|
84
|
-
const inputs = Array.isArray(pathsInput) ? pathsInput : [pathsInput];
|
|
85
|
-
const markdownFiles = [];
|
|
86
|
-
|
|
87
|
-
for (const input of inputs) {
|
|
88
|
-
if (typeof input !== 'string' || !input.trim()) {
|
|
89
|
-
throw new Error('paths中的每一项都必须是非空字符串');
|
|
90
|
-
}
|
|
91
|
-
if (!path.isAbsolute(input.trim())) {
|
|
92
|
-
throw new Error(`必须使用绝对路径: ${input}`);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const absolutePath = path.resolve(input.trim());
|
|
96
|
-
const pathStat = await stat(absolutePath);
|
|
97
|
-
if (pathStat.isDirectory()) {
|
|
98
|
-
markdownFiles.push(...(await scanMarkdownDirectory(absolutePath)));
|
|
99
|
-
} else if (pathStat.isFile() && path.extname(absolutePath).toLowerCase() === '.md') {
|
|
100
|
-
markdownFiles.push(absolutePath);
|
|
101
|
-
} else {
|
|
102
|
-
throw new Error(`路径不是Markdown文件或文件夹: ${absolutePath}`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const uniquePaths = [...new Set(markdownFiles)].sort((left, right) => left.localeCompare(right));
|
|
107
|
-
if (uniquePaths.length === 0) {
|
|
108
|
-
throw new Error('指定路径中没有找到任何.md文件');
|
|
109
|
-
}
|
|
110
|
-
return uniquePaths;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
64
|
/** Posts JSON to Express and returns its parsed response body. */
|
|
114
65
|
async function postJson(endpoint, body) {
|
|
66
|
+
const serializedBody = serializeRequestBody(body);
|
|
115
67
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
116
68
|
method: 'POST',
|
|
117
69
|
headers: { 'content-type': 'application/json' },
|
|
118
|
-
body:
|
|
70
|
+
body: serializedBody,
|
|
119
71
|
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
|
120
72
|
});
|
|
121
73
|
const responseText = await response.text();
|
|
@@ -210,12 +162,13 @@ server.registerTool(
|
|
|
210
162
|
async ({ paths, platform, module, page }) => {
|
|
211
163
|
try {
|
|
212
164
|
const expandedPaths = await expandMarkdownPaths(paths);
|
|
165
|
+
const documents = await buildDocuments(expandedPaths);
|
|
213
166
|
return toolSuccess(
|
|
214
167
|
await postJson('/add-prd', {
|
|
215
|
-
|
|
168
|
+
documents,
|
|
216
169
|
platform,
|
|
217
170
|
module,
|
|
218
|
-
page
|
|
171
|
+
page: documents.length === 1 ? page : undefined
|
|
219
172
|
})
|
|
220
173
|
);
|
|
221
174
|
} catch (error) {
|
|
@@ -248,14 +201,15 @@ server.registerTool(
|
|
|
248
201
|
async ({ paths, sprintVersion, iterationTag, platform, module, page }) => {
|
|
249
202
|
try {
|
|
250
203
|
const expandedPaths = await expandMarkdownPaths(paths);
|
|
204
|
+
const documents = await buildDocuments(expandedPaths);
|
|
251
205
|
return toolSuccess(
|
|
252
206
|
await postJson('/add-spec', {
|
|
253
|
-
|
|
207
|
+
documents,
|
|
254
208
|
sprintVersion,
|
|
255
209
|
iterationTag,
|
|
256
210
|
platform,
|
|
257
211
|
module,
|
|
258
|
-
page
|
|
212
|
+
page: documents.length === 1 ? page : undefined
|
|
259
213
|
})
|
|
260
214
|
);
|
|
261
215
|
} catch (error) {
|
|
@@ -1,52 +1,279 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import os from 'node:os';
|
|
3
6
|
import path from 'node:path';
|
|
4
|
-
import test from 'node:test';
|
|
7
|
+
import { after, before, test } from 'node:test';
|
|
5
8
|
import { fileURLToPath } from 'node:url';
|
|
6
9
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
10
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
11
|
+
import {
|
|
12
|
+
MAX_IMAGE_BYTES,
|
|
13
|
+
MAX_REQUEST_BYTES,
|
|
14
|
+
buildDocuments,
|
|
15
|
+
expandMarkdownPaths,
|
|
16
|
+
serializeRequestBody
|
|
17
|
+
} from './documents.js';
|
|
8
18
|
|
|
9
19
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]);
|
|
10
21
|
|
|
11
|
-
|
|
12
|
-
|
|
22
|
+
let apiServer;
|
|
23
|
+
let client;
|
|
24
|
+
let temporaryDirectory;
|
|
25
|
+
const requests = [];
|
|
26
|
+
|
|
27
|
+
async function readRequestBody(request) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
30
|
+
return chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function callTool(name, args) {
|
|
34
|
+
return client.callTool({ name, arguments: args });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toolText(result) {
|
|
38
|
+
return result.content.map(item => item.text ?? '').join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
before(async () => {
|
|
42
|
+
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'rag-mcp-bridge-'));
|
|
43
|
+
apiServer = createServer(async (request, response) => {
|
|
44
|
+
const url = new URL(request.url, 'http://localhost');
|
|
45
|
+
requests.push({
|
|
46
|
+
method: request.method,
|
|
47
|
+
path: url.pathname,
|
|
48
|
+
query: Object.fromEntries(url.searchParams),
|
|
49
|
+
body: await readRequestBody(request)
|
|
50
|
+
});
|
|
51
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
52
|
+
response.end(JSON.stringify({ ok: true, endpoint: url.pathname }));
|
|
53
|
+
});
|
|
54
|
+
await new Promise(resolve => apiServer.listen(0, '127.0.0.1', resolve));
|
|
55
|
+
const address = apiServer.address();
|
|
56
|
+
const apiBaseUrl = `http://127.0.0.1:${address.port}/api/knowledge`;
|
|
57
|
+
|
|
58
|
+
client = new Client({ name: 'rag-mcp-bridge-test', version: '1.0.0' });
|
|
13
59
|
const transport = new StdioClientTransport({
|
|
14
60
|
command: process.execPath,
|
|
15
61
|
args: [path.join(directory, 'index.js')],
|
|
16
62
|
cwd: path.resolve(directory, '../..'),
|
|
17
|
-
env: {
|
|
18
|
-
RAG_API_BASE_URL: 'http://192.168.1.12:3000/api/knowledge'
|
|
19
|
-
},
|
|
63
|
+
env: { RAG_API_BASE_URL: apiBaseUrl },
|
|
20
64
|
stderr: 'pipe'
|
|
21
65
|
});
|
|
66
|
+
await client.connect(transport);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
after(async () => {
|
|
70
|
+
await client?.close();
|
|
71
|
+
await new Promise(resolve => apiServer?.close(resolve));
|
|
72
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('initializes and preserves the existing MCP tool contract', async () => {
|
|
76
|
+
assert.equal(client.getServerVersion()?.name, 'rag-mcp-bridge');
|
|
77
|
+
assert.match(client.getInstructions() ?? '', /retrieve_knowledge/);
|
|
78
|
+
const { tools } = await client.listTools();
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
tools.map(({ name }) => name),
|
|
81
|
+
['add_prd_file', 'update_spec_file', 'retrieve_knowledge']
|
|
82
|
+
);
|
|
83
|
+
assert.equal(tools.find(({ name }) => name === 'retrieve_knowledge')?.annotations?.readOnlyHint, true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('add_prd_file uploads one Unicode Markdown file and its encoded local image', async () => {
|
|
87
|
+
const root = path.join(temporaryDirectory, '单文件 根目录');
|
|
88
|
+
const docs = path.join(root, '页面 文档');
|
|
89
|
+
const attachments = path.join(root, '.attachments');
|
|
90
|
+
await Promise.all([mkdir(docs, { recursive: true }), mkdir(attachments, { recursive: true })]);
|
|
91
|
+
const markdownPath = path.join(docs, '首页 需求.md');
|
|
92
|
+
const imageReference = '../.attachments/%E7%A4%BA%E4%BE%8B%20%E5%9B%BE%E7%89%87.png';
|
|
93
|
+
const markdown = `# 首页\n\n![本地图片][local-shot]\n\n\n\n[local-shot]: ${imageReference}\n`;
|
|
94
|
+
await Promise.all([writeFile(markdownPath, markdown), writeFile(path.join(attachments, '示例 图片.png'), png)]);
|
|
95
|
+
|
|
96
|
+
const result = await callTool('add_prd_file', {
|
|
97
|
+
paths: markdownPath,
|
|
98
|
+
platform: 'app',
|
|
99
|
+
module: 'vehicle-selector',
|
|
100
|
+
page: 'home'
|
|
101
|
+
});
|
|
102
|
+
assert.notEqual(result.isError, true);
|
|
103
|
+
const request = requests.at(-1);
|
|
104
|
+
assert.equal(request.path, '/api/knowledge/add-prd');
|
|
105
|
+
assert.equal('paths' in request.body, false);
|
|
106
|
+
assert.equal(request.body.page, 'home');
|
|
107
|
+
assert.equal(request.body.documents.length, 1);
|
|
108
|
+
assert.deepEqual(request.body.documents[0], {
|
|
109
|
+
docKey: path.resolve(markdownPath),
|
|
110
|
+
fileName: '首页 需求.md',
|
|
111
|
+
markdown,
|
|
112
|
+
assets: [{ path: imageReference, mimeType: 'image/png', base64: png.toString('base64') }]
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('file arrays and recursive directories upload all documents, omit page, and preserve shared assets', async () => {
|
|
117
|
+
const root = path.join(temporaryDirectory, '批量');
|
|
118
|
+
const docs = path.join(root, 'docs');
|
|
119
|
+
const nested = path.join(docs, 'nested');
|
|
120
|
+
const attachments = path.join(root, '.attachments');
|
|
121
|
+
await Promise.all([mkdir(nested, { recursive: true }), mkdir(attachments, { recursive: true })]);
|
|
122
|
+
await writeFile(path.join(attachments, 'shared.png'), png);
|
|
123
|
+
const first = path.join(docs, 'PL.md');
|
|
124
|
+
const second = path.join(docs, 'PN.md');
|
|
125
|
+
const third = path.join(nested, 'PD.md');
|
|
126
|
+
await Promise.all([
|
|
127
|
+
writeFile(first, ''),
|
|
128
|
+
writeFile(second, ''),
|
|
129
|
+
writeFile(third, '# no local image')
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
await callTool('add_prd_file', { paths: [first, second], platform: 'app', page: 'pl' });
|
|
133
|
+
const arrayRequest = requests.at(-1);
|
|
134
|
+
assert.equal(arrayRequest.body.page, undefined);
|
|
135
|
+
assert.equal(arrayRequest.body.documents.length, 2);
|
|
136
|
+
assert.equal(arrayRequest.body.documents[0].assets[0].base64, png.toString('base64'));
|
|
137
|
+
assert.equal(arrayRequest.body.documents[1].assets[0].base64, png.toString('base64'));
|
|
138
|
+
|
|
139
|
+
await callTool('add_prd_file', { paths: docs, platform: 'app', module: 'vehicle-selector' });
|
|
140
|
+
const directoryRequest = requests.at(-1);
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
directoryRequest.body.documents.map(document => document.fileName),
|
|
143
|
+
['PD.md', 'PL.md', 'PN.md']
|
|
144
|
+
);
|
|
145
|
+
assert.equal(directoryRequest.body.page, undefined);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('update_spec_file uses documents mode and preserves iteration metadata', async () => {
|
|
149
|
+
const specPath = path.join(temporaryDirectory, '迭代 spec.md');
|
|
150
|
+
await writeFile(specPath, '# iteration');
|
|
151
|
+
await callTool('update_spec_file', {
|
|
152
|
+
paths: specPath,
|
|
153
|
+
sprintVersion: 12,
|
|
154
|
+
iterationTag: '2026-S12',
|
|
155
|
+
platform: 'california-web',
|
|
156
|
+
module: 'vehicle-selector',
|
|
157
|
+
page: 'pd'
|
|
158
|
+
});
|
|
159
|
+
const request = requests.at(-1);
|
|
160
|
+
assert.equal(request.path, '/api/knowledge/add-spec');
|
|
161
|
+
assert.equal('paths' in request.body, false);
|
|
162
|
+
assert.equal(request.body.sprintVersion, 12);
|
|
163
|
+
assert.equal(request.body.iterationTag, '2026-S12');
|
|
164
|
+
assert.equal(request.body.page, 'pd');
|
|
165
|
+
assert.equal(request.body.documents[0].markdown, '# iteration');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('retrieve_knowledge remains a GET request with the existing query mapping', async () => {
|
|
169
|
+
await callTool('retrieve_knowledge', {
|
|
170
|
+
query: 'selector requirements',
|
|
171
|
+
platform: 'app',
|
|
172
|
+
module: 'vehicle-selector',
|
|
173
|
+
page: 'home',
|
|
174
|
+
enableStruct: false,
|
|
175
|
+
includeImages: false
|
|
176
|
+
});
|
|
177
|
+
const request = requests.at(-1);
|
|
178
|
+
assert.equal(request.method, 'GET');
|
|
179
|
+
assert.equal(request.path, '/api/knowledge/search');
|
|
180
|
+
assert.deepEqual(request.query, {
|
|
181
|
+
query: 'selector requirements',
|
|
182
|
+
platform: 'app',
|
|
183
|
+
module: 'vehicle-selector',
|
|
184
|
+
page: 'home',
|
|
185
|
+
enableStruct: 'false',
|
|
186
|
+
includeImages: 'false'
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('reports missing images with the Markdown file and referenced path', async () => {
|
|
191
|
+
const markdownPath = path.join(temporaryDirectory, '缺图.md');
|
|
192
|
+
await writeFile(markdownPath, '');
|
|
193
|
+
const result = await callTool('add_prd_file', { paths: markdownPath, platform: 'app' });
|
|
194
|
+
assert.equal(result.isError, true);
|
|
195
|
+
assert.match(toolText(result), /缺图\.md/);
|
|
196
|
+
assert.match(toolText(result), /images\/not-found\.png/);
|
|
197
|
+
assert.match(toolText(result), /不存在/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('rejects unsafe image inputs', async t => {
|
|
201
|
+
await t.test('unsupported extension', async () => {
|
|
202
|
+
const markdownPath = path.join(temporaryDirectory, '非法类型.md');
|
|
203
|
+
await writeFile(markdownPath, '');
|
|
204
|
+
const result = await callTool('add_prd_file', { paths: markdownPath, platform: 'app' });
|
|
205
|
+
assert.equal(result.isError, true);
|
|
206
|
+
assert.match(toolText(result), /不受支持的图片类型/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await t.test('MIME signature mismatch', async () => {
|
|
210
|
+
const markdownPath = path.join(temporaryDirectory, '伪装图片.md');
|
|
211
|
+
await Promise.all([
|
|
212
|
+
writeFile(markdownPath, ''),
|
|
213
|
+
writeFile(path.join(temporaryDirectory, 'fake.png'), 'secret')
|
|
214
|
+
]);
|
|
215
|
+
const result = await callTool('add_prd_file', { paths: markdownPath, platform: 'app' });
|
|
216
|
+
assert.equal(result.isError, true);
|
|
217
|
+
assert.match(toolText(result), /内容与扩展名不匹配/);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await t.test('single image size limit', async () => {
|
|
221
|
+
const markdownPath = path.join(temporaryDirectory, '超大图片.md');
|
|
222
|
+
const oversized = Buffer.alloc(MAX_IMAGE_BYTES + 1);
|
|
223
|
+
png.copy(oversized);
|
|
224
|
+
await Promise.all([
|
|
225
|
+
writeFile(markdownPath, ''),
|
|
226
|
+
writeFile(path.join(temporaryDirectory, 'large.png'), oversized)
|
|
227
|
+
]);
|
|
228
|
+
const result = await callTool('add_prd_file', { paths: markdownPath, platform: 'app' });
|
|
229
|
+
assert.equal(result.isError, true);
|
|
230
|
+
assert.match(toolText(result), /超过单文件限制/);
|
|
231
|
+
});
|
|
22
232
|
|
|
23
|
-
|
|
24
|
-
|
|
233
|
+
await t.test('path traversal outside approved attachment roots', async () => {
|
|
234
|
+
const docs = path.join(temporaryDirectory, 'traversal', 'docs');
|
|
235
|
+
await mkdir(docs, { recursive: true });
|
|
236
|
+
const markdownPath = path.join(docs, '越界.md');
|
|
237
|
+
await writeFile(markdownPath, '');
|
|
238
|
+
const result = await callTool('add_prd_file', { paths: markdownPath, platform: 'app' });
|
|
239
|
+
assert.equal(result.isError, true);
|
|
240
|
+
assert.match(toolText(result), /越界\.md/);
|
|
241
|
+
assert.match(toolText(result), /\.\.\/secret\.png/);
|
|
242
|
+
assert.match(toolText(result), /路径越界/);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
25
245
|
|
|
26
|
-
|
|
27
|
-
|
|
246
|
+
test('enforces the total request size while packaging and serializing', async () => {
|
|
247
|
+
const markdownPath = path.join(temporaryDirectory, '超大文档.md');
|
|
248
|
+
await writeFile(markdownPath, 'x'.repeat(MAX_REQUEST_BYTES + 1));
|
|
249
|
+
await assert.rejects(() => buildDocuments([markdownPath]), /内容超过单次请求限制/);
|
|
250
|
+
assert.throws(
|
|
251
|
+
() => serializeRequestBody({ documents: [{ markdown: 'x'.repeat(MAX_REQUEST_BYTES) }] }),
|
|
252
|
+
/超过单次限制/
|
|
253
|
+
);
|
|
254
|
+
});
|
|
28
255
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
256
|
+
test('expands files, arrays, and recursive directories in stable order', async () => {
|
|
257
|
+
const root = path.join(temporaryDirectory, 'expand');
|
|
258
|
+
const nested = path.join(root, 'nested');
|
|
259
|
+
await mkdir(nested, { recursive: true });
|
|
260
|
+
const a = path.join(root, 'a.md');
|
|
261
|
+
const b = path.join(nested, 'b.md');
|
|
262
|
+
await Promise.all([writeFile(a, '# a'), writeFile(b, '# b'), writeFile(path.join(root, 'ignored.txt'), 'x')]);
|
|
263
|
+
assert.deepEqual(await expandMarkdownPaths(a), [a]);
|
|
264
|
+
assert.deepEqual(await expandMarkdownPaths([b, a]), [a, b]);
|
|
265
|
+
assert.deepEqual(await expandMarkdownPaths(root), [a, b]);
|
|
266
|
+
assert.equal((await buildDocuments([a]))[0].markdown, '# a');
|
|
38
267
|
});
|
|
39
268
|
|
|
40
269
|
test('fails to start when RAG_API_BASE_URL is not configured', () => {
|
|
41
270
|
const environment = { ...process.env };
|
|
42
271
|
delete environment.RAG_API_BASE_URL;
|
|
43
|
-
|
|
44
272
|
const result = spawnSync(process.execPath, [path.join(directory, 'index.js')], {
|
|
45
273
|
cwd: path.resolve(directory, '../..'),
|
|
46
274
|
env: environment,
|
|
47
275
|
encoding: 'utf8'
|
|
48
276
|
});
|
|
49
|
-
|
|
50
277
|
assert.notEqual(result.status, 0);
|
|
51
278
|
assert.match(result.stderr, /RAG_API_BASE_URL is required/);
|
|
52
279
|
});
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"start": "node index.js",
|
|
13
|
-
"lint": "node --check index.js && node --check index.test.js",
|
|
13
|
+
"lint": "node --check index.js && node --check documents.js && node --check index.test.js",
|
|
14
14
|
"test": "node --test index.test.js",
|
|
15
15
|
"verify": "npm run lint && npm test"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
19
|
+
"mdast-util-from-markdown": "2.0.2",
|
|
19
20
|
"zod": "4.4.3"
|
|
20
21
|
}
|
|
21
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autobest-ui/agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Autobest Agent skills/plugins/mcp assets + sync cli",
|
|
6
6
|
"files": [
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
40
40
|
"diff": "9.0.0",
|
|
41
41
|
"fs-extra": "11.3.0",
|
|
42
|
+
"mdast-util-from-markdown": "2.0.2",
|
|
42
43
|
"ws": "8.18.3",
|
|
43
44
|
"zod": "4.4.3"
|
|
44
45
|
},
|