yike-ui-mcp 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  "mcpServers": {
12
12
  "yike-ui-mcp": {
13
13
  "command": "npx",
14
- "args": ["-y", "yike-ui-mcp@1.0.0"]
14
+ "args": ["-y", "yike-ui-mcp"]
15
15
  }
16
16
  }
17
17
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "yike-ui-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "益客lanhu mcp",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
- "files": ["src/index.js"],
7
+ "files": ["src"],
8
8
  "bin": {
9
9
  "yike-ui-mcp": "src/index.js"
10
10
  },
@@ -0,0 +1,197 @@
1
+ const NOISE_KEYS = new Set([
2
+ 'boundVariables',
3
+ 'transform',
4
+ 'combinedFrame',
5
+ 'realFrame',
6
+ 'clipped',
7
+ 'hasExportDDSImage',
8
+ 'hasExportImage',
9
+ 'isMask',
10
+ 'origin',
11
+ 'paths',
12
+ 'style',
13
+ 'layers',
14
+ 'text',
15
+ 'image',
16
+ 'frame',
17
+ 'isEnabled',
18
+ ]);
19
+
20
+ const round = (value) => Math.round(value * 100) / 100;
21
+
22
+ const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
23
+
24
+ function colorToString(color) {
25
+ if (!color) return undefined;
26
+ if (typeof color === 'string') return color;
27
+ if (color.value) return color.value;
28
+ if ([color.r, color.g, color.b].every(isNumber)) {
29
+ const toByte = (channel) => Math.round(channel * 255);
30
+ return `rgba(${toByte(color.r)},${toByte(color.g)},${toByte(color.b)},${color.a ?? 1})`;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ const isDefaultValue = (key, value) =>
36
+ (key === 'opacity' && value === 1) ||
37
+ (key === 'blendMode' && value === 0) ||
38
+ (key === 'visible' && value === true) ||
39
+ (key === 'rotation' && value === 0);
40
+
41
+ function prune(value) {
42
+ if (Array.isArray(value)) {
43
+ const items = value.map(prune).filter((item) => item !== undefined && item !== null);
44
+ return items.length ? items : undefined;
45
+ }
46
+ if (value && typeof value === 'object') {
47
+ if ([value.r, value.g, value.b].every(isNumber)) return colorToString(value);
48
+ const result = {};
49
+ for (const [key, raw] of Object.entries(value)) {
50
+ if (NOISE_KEYS.has(key)) continue;
51
+ if (isDefaultValue(key, raw)) continue;
52
+ const pruned = prune(raw);
53
+ if (pruned === undefined || pruned === null) continue;
54
+ if (typeof pruned === 'object' && Object.keys(pruned).length === 0) continue;
55
+ result[key] = pruned;
56
+ }
57
+ return Object.keys(result).length ? result : undefined;
58
+ }
59
+ return value;
60
+ }
61
+
62
+ function frameToArray(frame) {
63
+ if (!frame) return undefined;
64
+ const values = [frame.left, frame.top, frame.width, frame.height];
65
+ return values.every(isNumber) ? values.map(round) : undefined;
66
+ }
67
+
68
+ function radiusOf(layer) {
69
+ const radius = layer.radius;
70
+ if (!radius) return undefined;
71
+ const corners = [radius.topLeft, radius.topRight, radius.bottomRight, radius.bottomLeft].map((value) => (isNumber(value) ? round(value) : 0));
72
+ if (corners.every((value) => value === 0)) return undefined;
73
+ return corners.every((value) => value === corners[0]) ? corners[0] : corners;
74
+ }
75
+
76
+ function paintsOf(paints, fallbackKey) {
77
+ if (!Array.isArray(paints)) return undefined;
78
+ const enabled = paints.filter((paint) => paint && paint.isEnabled !== false);
79
+ if (!enabled.length) return undefined;
80
+ const simplified = enabled.map((paint) => {
81
+ if (paint.type === 'color') {
82
+ const color = colorToString(paint.color);
83
+ const opacity = isNumber(paint.opacity) && paint.opacity !== 1 ? round(paint.opacity) : undefined;
84
+ if (!color) return prune(paint);
85
+ return opacity === undefined ? color : { color, opacity };
86
+ }
87
+ const rest = prune(paint);
88
+ if (!rest) return paint.type;
89
+ return rest;
90
+ }).filter((item) => item !== undefined);
91
+ if (!simplified.length) return undefined;
92
+ if (simplified.length === 1) return simplified[0];
93
+ return { [fallbackKey]: simplified };
94
+ }
95
+
96
+ function textOf(layer) {
97
+ const text = layer.text;
98
+ if (!text) return undefined;
99
+ const styles = Array.isArray(text.styles) && text.styles.length ? text.styles : [text.style];
100
+ const main = styles[0] ?? {};
101
+ const font = main.font ?? {};
102
+ const result = { value: text.value ?? main.content };
103
+
104
+ if (font.name || font.postScriptName) result.font = font.name ?? font.postScriptName;
105
+ if (isNumber(font.size)) result.size = round(font.size);
106
+ if (isNumber(font.fontWeight)) result.weight = font.fontWeight;
107
+ if (font.bold) result.bold = true;
108
+ if (font.italic) result.italic = true;
109
+ if (font.align) result.align = font.align;
110
+ if (isNumber(font.lineHeight?.value)) result.lineHeight = round(font.lineHeight.value);
111
+ if (isNumber(font.letterSpacing?.value) && font.letterSpacing.value !== 0) result.letterSpacing = round(font.letterSpacing.value);
112
+
113
+ const color = colorToString(main.color);
114
+ if (color) result.color = color;
115
+
116
+ if (styles.length > 1) {
117
+ result.styles = styles.map((style) => {
118
+ const segment = { from: style.from, to: style.to };
119
+ if (style.content) segment.text = style.content;
120
+ const segmentColor = colorToString(style.color);
121
+ if (segmentColor) segment.color = segmentColor;
122
+ const segmentFont = style.font ?? {};
123
+ if (isNumber(segmentFont.size)) segment.size = round(segmentFont.size);
124
+ if (segmentFont.fontWeight) segment.weight = segmentFont.fontWeight;
125
+ if (segmentFont.name) segment.font = segmentFont.name;
126
+ return segment;
127
+ });
128
+ }
129
+
130
+ return result;
131
+ }
132
+
133
+ function imageOf(layer) {
134
+ const image = layer.image;
135
+ if (!image) return undefined;
136
+ const result = {};
137
+ const png = image.imageUrl ?? image.url;
138
+ const svg = image.svgUrl;
139
+ if (png) result.png = png;
140
+ if (svg) result.svg = svg;
141
+ return Object.keys(result).length ? result : undefined;
142
+ }
143
+
144
+ function compactLayer(layer) {
145
+ const node = {};
146
+ if (layer.id) node.id = layer.id;
147
+ if (layer.name) node.name = layer.name;
148
+ if (layer.type) node.type = layer.type;
149
+ if (layer.componentName) node.component = layer.componentName;
150
+
151
+ const frame = frameToArray(layer.realFrame ?? layer.frame);
152
+ if (frame) node.frame = frame;
153
+
154
+ const shape = layer.paths?.[0]?.type;
155
+ if (shape) node.shape = shape;
156
+
157
+ if (layer.visible === false) node.visible = false;
158
+ if (isNumber(layer.opacity) && layer.opacity !== 1) node.opacity = round(layer.opacity);
159
+ if (isNumber(layer.rotation) && layer.rotation !== 0) node.rotation = round(layer.rotation);
160
+
161
+ const radius = radiusOf(layer);
162
+ if (radius !== undefined) node.radius = radius;
163
+
164
+ const style = layer.style ?? {};
165
+ const fill = paintsOf(style.fills, 'fills');
166
+ if (fill !== undefined) node.fill = fill;
167
+
168
+ const border = paintsOf(style.borders, 'borders');
169
+ if (border !== undefined) node.border = border;
170
+
171
+ const shadow = paintsOf(style.shadows, 'shadows');
172
+ if (shadow !== undefined) node.shadow = shadow;
173
+
174
+ const blur = paintsOf(style.blurs, 'blurs');
175
+ if (blur !== undefined) node.blur = blur;
176
+
177
+ const text = textOf(layer);
178
+ if (text) node.text = text;
179
+
180
+ const image = imageOf(layer);
181
+ if (image) node.image = image;
182
+
183
+ if (Array.isArray(layer.layers) && layer.layers.length) {
184
+ node.children = layer.layers.map(compactLayer);
185
+ }
186
+
187
+ return node;
188
+ }
189
+
190
+ export function compactLayers(layers) {
191
+ return Array.isArray(layers) ? layers.map(compactLayer) : [];
192
+ }
193
+
194
+ export function compactAssets(assets) {
195
+ if (!assets) return undefined;
196
+ return prune(assets) ?? assets;
197
+ }
package/src/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
+ import { compactAssets, compactLayers } from './compact-layers.js';
5
6
 
6
- const server = new McpServer({ name: 'simple-mcp-server', version: '1.0.0' });
7
+ const server = new McpServer({ name: 'yike-ui-server', version: '1.0.0' });
7
8
 
8
9
  server.registerTool(
9
10
  'greet',
@@ -13,14 +14,98 @@ server.registerTool(
13
14
  },
14
15
  async ({ name }) => ({
15
16
  content: [
16
- { type: 'text', text: `你好,${name}!` }
17
+ { type: 'text', text: `你好啊~~,${name}!` }
17
18
  ]
18
19
  }),
19
20
  );
20
21
 
22
+ server.registerTool(
23
+ 'get_lanhu_ui',
24
+ {
25
+ description: '根据蓝湖设计稿网址获取 UI 信息(网址需包含 image_id、team_id、project_id)',
26
+ inputSchema: { url: z.string().min(1).describe('蓝湖设计稿网址') },
27
+ },
28
+ async ({ url }) => {
29
+ const error = (message) => ({ isError: true, content: [{ type: 'text', text: message }] });
30
+ const cookie = process.env.LANHU_COOKIE;
31
+ if (!cookie?.trim()) return error('缺少环境变量 LANHU_COOKIE');
32
+
33
+ let pageUrl;
34
+ try {
35
+ pageUrl = new URL(url);
36
+ if (pageUrl.protocol !== 'https:' || pageUrl.hostname !== 'lanhuapp.com') {
37
+ return error('请输入 https://lanhuapp.com 的蓝湖网址');
38
+ }
39
+ } catch {
40
+ return error('蓝湖网址无效');
41
+ }
42
+
43
+ const fragment = pageUrl.hash.slice(1);
44
+ const params = new URL(fragment || '/', pageUrl.origin).searchParams;
45
+ for (const key of ['image_id', 'tid', 'project_id']) {
46
+ if (!params.get(key)?.trim()) return error(`蓝湖网址缺少参数 ${key}`);
47
+ }
48
+
49
+ const apiUrl = new URL('/api/project/image', process.env.LANHU_API_BASE_URL || 'https://lanhuapp.com');
50
+ apiUrl.search = new URLSearchParams({
51
+ dds_status: '1',
52
+ image_id: params.get('image_id'),
53
+ team_id: params.get('tid'),
54
+ project_id: params.get('project_id'),
55
+ all_versions: '0',
56
+ }).toString();
57
+
58
+ try {
59
+ const response = await fetch(apiUrl, {
60
+ headers: {
61
+ accept: 'application/json, text/plain, */*',
62
+ cookie,
63
+ 'real-path': '/item/project/detailDetach',
64
+ referer: 'https://lanhuapp.com/web/',
65
+ 'request-from': 'web',
66
+ },
67
+ signal: AbortSignal.timeout(15000),
68
+ });
69
+ const body = await response.json();
70
+ if (!response.ok) return error(`蓝湖接口请求失败(HTTP ${response.status}):${JSON.stringify(body).slice(0, 500)}`);
71
+ if (!body?.result?.versions?.length) return error(`当前链接没有图层信息!`);
72
+ const response1 = await fetch(body.result.versions[0].json_url, {
73
+ headers: {
74
+ accept: 'application/json, text/plain, */*',
75
+ cookie,
76
+ 'real-path': '/item/project/detailDetach',
77
+ referer: 'https://lanhuapp.com/web/',
78
+ 'request-from': 'web',
79
+ },
80
+ signal: AbortSignal.timeout(15000),
81
+ });
82
+ if (!response1.ok) return error(`图层获取失败!`);
83
+ const body1 = await response1.json();
84
+ let content = `
85
+ 这是一段蓝湖设计稿信息,包含设计稿实现硬约束,设计稿信息,设计稿图层信息,切图信息,请参考这些信息结合当前项目情况生成前端代码。
86
+ ## 设计稿实现硬约束
87
+ - 实现设计稿时,默认忽略所有非业务性的顶部展示区域;除非本次任务明确要求保留。非业务性的顶部展示区域包括但不限于:系统状态栏、设备外壳、展示用头部、返回按钮、页面标题、右上角胶囊按钮、机型边框
88
+ - 最外层容器若有来自设计画布的固定高度,应去掉或者配置 min-h-screen 等,让内容撑开(除非固定高度属于设计意图,如固定头部、Banner 等)
89
+ ## 设计稿信息
90
+ - 名称:${body.result.name}
91
+ - 概览图:${body.result.url.replace("alipic", "assets")}
92
+ ## 切图信息
93
+ 切图列表:${JSON.stringify(compactAssets(body1.assets))}
94
+ 需要让用户选择切图下载到哪个文件夹下
95
+ ## 设计稿图层信息
96
+ 字段说明:frame 为 [x, y, width, height];fill/border/shadow 为颜色或样式;children 为子图层。
97
+ ${JSON.stringify(compactLayers(body1.artboard?.layers))}
98
+ `;
99
+ return { content: [{ type: 'text', text: content }] };
100
+ } catch (cause) {
101
+ return error(`蓝湖接口请求失败:${cause.message}`);
102
+ }
103
+ },
104
+ );
105
+
21
106
  try {
22
107
  await server.connect(new StdioServerTransport());
23
108
  } catch (error) {
24
109
  console.error('MCP 服务启动失败:', error);
25
110
  process.exitCode = 1;
26
- }
111
+ }