@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.
@@ -1,6 +1,6 @@
1
1
  # rag-mcp-bridge
2
2
 
3
- `rag-mcp-bridge` 是 PRD Knowledge RAG API 的本地 STDIO MCP 适配器。它负责展开 Markdown 路径并把 MCP 工具调用转发给 HTTP API;文档解析、Embedding、数据库读写和检索仍由 Express 服务完成。
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` 仅执行文件夹扫描和HTTP转发。文档解析、截图提取、Embedding、RAG与PostgreSQL操作全部由Express后端完成。
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. Expanding file, file-array, and directory inputs into Markdown paths.
8
- * 2. Forwarding MCP tool calls to the local HTTP API.
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
- * Database access, Markdown parsing, embeddings, and RAG stay in Express.
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: JSON.stringify(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
- paths: expandedPaths,
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
- paths: expandedPaths,
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
- test('initializes and exposes the RAG tools over stdio', async () => {
12
- const client = new Client({ name: 'rag-mcp-bridge-test', version: '1.0.0' });
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![远端](https://example.com/a.png)\n![内嵌](data:image/png;base64,AAAA)\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, '![shared](../.attachments/shared.png)'),
128
+ writeFile(second, '![shared](../.attachments/shared.png)'),
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, '![missing](images/not-found.png)');
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, '![svg](image.svg)');
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, '![fake](fake.png)'),
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, '![large](large.png)'),
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
- try {
24
- await client.connect(transport);
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, '![outside](../secret.png)');
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
- assert.equal(client.getServerVersion()?.name, 'rag-mcp-bridge');
27
- assert.match(client.getInstructions() ?? '', /retrieve_knowledge/);
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
- const { tools } = await client.listTools();
30
- assert.deepEqual(
31
- tools.map(({ name }) => name),
32
- ['add_prd_file', 'update_spec_file', 'retrieve_knowledge']
33
- );
34
- assert.equal(tools.find(({ name }) => name === 'retrieve_knowledge')?.annotations?.readOnlyHint, true);
35
- } finally {
36
- await client.close();
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.2",
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
  },