@autobest-ui/agent 1.0.1 → 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.
@@ -5,7 +5,8 @@
5
5
 
6
6
  export const nodesTool = {
7
7
  name: 'figma_get_nodes',
8
- description: 'Get detailed information about specific Figma nodes by their IDs. Returns node properties including type, position, size, fills, strokes, auto-layout (including layoutWrap and counterAxisSpacing), clipsContent, node-level boundVariables, explicitVariableModes, and more.',
8
+ description:
9
+ 'Get detailed information about specific Figma nodes by their IDs. Returns node properties including type, position, size, fills, strokes, auto-layout (including layoutWrap and counterAxisSpacing), clipsContent, node-level boundVariables, explicitVariableModes, and more.',
9
10
  inputSchema: {
10
11
  type: 'object',
11
12
  properties: {
@@ -22,15 +23,21 @@ export const nodesTool = {
22
23
  export async function handleGetNodes(bridge, args) {
23
24
  if (!bridge.isConnected()) {
24
25
  return {
25
- content: [{
26
- type: 'text',
27
- text: JSON.stringify({
28
- error: {
29
- code: 'NOT_CONNECTED',
30
- message: 'Figma plugin is not connected. Please open Figma and run the Autobest Figma Plugin.'
31
- }
32
- }, null, 2)
33
- }],
26
+ content: [
27
+ {
28
+ type: 'text',
29
+ text: JSON.stringify(
30
+ {
31
+ error: {
32
+ code: 'NOT_CONNECTED',
33
+ message: 'Figma plugin is not connected. Please open Figma and run the Autobest Figma Plugin.'
34
+ }
35
+ },
36
+ null,
37
+ 2
38
+ )
39
+ }
40
+ ],
34
41
  isError: true
35
42
  };
36
43
  }
@@ -39,15 +46,21 @@ export async function handleGetNodes(bridge, args) {
39
46
 
40
47
  if (!nodeIds || !Array.isArray(nodeIds) || nodeIds.length === 0) {
41
48
  return {
42
- content: [{
43
- type: 'text',
44
- text: JSON.stringify({
45
- error: {
46
- code: 'INVALID_PARAMS',
47
- message: 'nodeIds must be a non-empty array of node IDs'
48
- }
49
- }, null, 2)
50
- }],
49
+ content: [
50
+ {
51
+ type: 'text',
52
+ text: JSON.stringify(
53
+ {
54
+ error: {
55
+ code: 'INVALID_PARAMS',
56
+ message: 'nodeIds must be a non-empty array of node IDs'
57
+ }
58
+ },
59
+ null,
60
+ 2
61
+ )
62
+ }
63
+ ],
51
64
  isError: true
52
65
  };
53
66
  }
@@ -56,22 +69,30 @@ export async function handleGetNodes(bridge, args) {
56
69
  const result = await bridge.sendCommand('get_nodes', { nodeIds, depth });
57
70
 
58
71
  return {
59
- content: [{
60
- type: 'text',
61
- text: JSON.stringify(result, null, 2)
62
- }]
72
+ content: [
73
+ {
74
+ type: 'text',
75
+ text: JSON.stringify(result, null, 2)
76
+ }
77
+ ]
63
78
  };
64
79
  } catch (error) {
65
80
  return {
66
- content: [{
67
- type: 'text',
68
- text: JSON.stringify({
69
- error: {
70
- code: error.code || 'UNKNOWN_ERROR',
71
- message: error.message
72
- }
73
- }, null, 2)
74
- }],
81
+ content: [
82
+ {
83
+ type: 'text',
84
+ text: JSON.stringify(
85
+ {
86
+ error: {
87
+ code: error.code || 'UNKNOWN_ERROR',
88
+ message: error.message
89
+ }
90
+ },
91
+ null,
92
+ 2
93
+ )
94
+ }
95
+ ],
75
96
  isError: true
76
97
  };
77
98
  }
@@ -5,7 +5,8 @@
5
5
 
6
6
  export const pagesTool = {
7
7
  name: 'figma_list_pages',
8
- description: 'List all pages in the current Figma document. Returns page IDs, names, and indicates which page is currently active.',
8
+ description:
9
+ 'List all pages in the current Figma document. Returns page IDs, names, and indicates which page is currently active.',
9
10
  inputSchema: {
10
11
  type: 'object',
11
12
  properties: {},
@@ -16,15 +17,21 @@ export const pagesTool = {
16
17
  export async function handleListPages(bridge) {
17
18
  if (!bridge.isConnected()) {
18
19
  return {
19
- content: [{
20
- type: 'text',
21
- text: JSON.stringify({
22
- error: {
23
- code: 'NOT_CONNECTED',
24
- message: 'Figma plugin is not connected. Please open Figma and run the Autobest Figma Plugin.'
25
- }
26
- }, null, 2)
27
- }],
20
+ content: [
21
+ {
22
+ type: 'text',
23
+ text: JSON.stringify(
24
+ {
25
+ error: {
26
+ code: 'NOT_CONNECTED',
27
+ message: 'Figma plugin is not connected. Please open Figma and run the Autobest Figma Plugin.'
28
+ }
29
+ },
30
+ null,
31
+ 2
32
+ )
33
+ }
34
+ ],
28
35
  isError: true
29
36
  };
30
37
  }
@@ -33,22 +40,30 @@ export async function handleListPages(bridge) {
33
40
  const result = await bridge.sendCommand('list_pages', {});
34
41
 
35
42
  return {
36
- content: [{
37
- type: 'text',
38
- text: JSON.stringify(result, null, 2)
39
- }]
43
+ content: [
44
+ {
45
+ type: 'text',
46
+ text: JSON.stringify(result, null, 2)
47
+ }
48
+ ]
40
49
  };
41
50
  } catch (error) {
42
51
  return {
43
- content: [{
44
- type: 'text',
45
- text: JSON.stringify({
46
- error: {
47
- code: error.code || 'UNKNOWN_ERROR',
48
- message: error.message
49
- }
50
- }, null, 2)
51
- }],
52
+ content: [
53
+ {
54
+ type: 'text',
55
+ text: JSON.stringify(
56
+ {
57
+ error: {
58
+ code: error.code || 'UNKNOWN_ERROR',
59
+ message: error.message
60
+ }
61
+ },
62
+ null,
63
+ 2
64
+ )
65
+ }
66
+ ],
52
67
  isError: true
53
68
  };
54
69
  }
@@ -1,13 +1,13 @@
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
 
7
7
  先启动 PRD Knowledge HTTP API。直接在终端验证时,必须在同一条命令中提供 API 根地址:
8
8
 
9
9
  ```bash
10
- RAG_API_BASE_URL="http://127.0.0.1:3000/api/knowledge" \
10
+ RAG_API_BASE_URL="http://192.168.1.12:3000/api/knowledge" \
11
11
  npx --yes \
12
12
  --package=@autobest-ui/agent@latest \
13
13
  autobest-rag-mcp
@@ -26,7 +26,7 @@ tool_timeout_sec = 120
26
26
  enabled = true
27
27
 
28
28
  [mcp_servers.rag-mcp-bridge.env]
29
- RAG_API_BASE_URL = "http://127.0.0.1:3000/api/knowledge"
29
+ RAG_API_BASE_URL = "http://192.168.1.12:3000/api/knowledge"
30
30
  RAG_MCP_HTTP_TIMEOUT_MS = "120000"
31
31
  ```
32
32
 
@@ -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后端完成。
@@ -7,6 +7,6 @@ enabled = true
7
7
 
8
8
  [mcp_servers.rag-mcp-bridge.env]
9
9
  # 必填:按实际部署地址修改,MCP 服务本身不提供默认地址。
10
- RAG_API_BASE_URL = "http://127.0.0.1:3000/api/knowledge"
10
+ RAG_API_BASE_URL = "http://192.168.1.12:3000/api/knowledge"
11
11
  # 可选:单次 HTTP 请求超时,单位为毫秒。
12
12
  RAG_MCP_HTTP_TIMEOUT_MS = "120000"
@@ -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
+ }