@ww_nero/mini-cli 1.0.56

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.
@@ -0,0 +1,478 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const chalk = require('chalk');
5
+
6
+ const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
7
+ const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
8
+
9
+ const { version: pkgVersion = '0.0.0' } = require('../../package.json');
10
+ const { ensureConfigFiles, getConfigPath } = require('../config');
11
+ const MAX_TOOL_NAME_LENGTH = 64;
12
+ const MCP_TYPE_HTTP = 'http';
13
+ const MCP_TYPE_STDIO = 'stdio';
14
+
15
+ const isPlainObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
16
+
17
+ const getFetchImpl = () => {
18
+ if (typeof fetch === 'function') return fetch;
19
+ try {
20
+ return require('node-fetch');
21
+ } catch (_) {
22
+ return null;
23
+ }
24
+ };
25
+
26
+ const normalizeHeadersInput = (headers, HeadersCtor) => {
27
+ if (!headers) return {};
28
+ if (HeadersCtor && headers instanceof HeadersCtor) {
29
+ return Object.fromEntries(headers.entries());
30
+ }
31
+ if (Array.isArray(headers)) {
32
+ return Object.fromEntries(headers);
33
+ }
34
+ if (isPlainObject(headers)) {
35
+ return { ...headers };
36
+ }
37
+ return {};
38
+ };
39
+
40
+ const mergeHeaders = (baseHeaders = {}, extraHeaders = {}, HeadersCtor) => ({
41
+ ...normalizeHeadersInput(baseHeaders, HeadersCtor),
42
+ ...normalizeHeadersInput(extraHeaders, HeadersCtor)
43
+ });
44
+
45
+ const normalizeHeaderValues = (headers) => {
46
+ if (!isPlainObject(headers)) return {};
47
+ const result = {};
48
+ Object.entries(headers).forEach(([key, value]) => {
49
+ if (typeof key !== 'string' || !key.trim()) return;
50
+ result[key.trim()] = value == null ? '' : String(value);
51
+ });
52
+ return result;
53
+ };
54
+
55
+ const createHttpFetch = (headers = {}) => {
56
+ const fetchImpl = getFetchImpl();
57
+ if (!fetchImpl) return null;
58
+ const HeadersCtor = typeof Headers !== 'undefined' ? Headers : fetchImpl.Headers;
59
+ const defaultHeaders = normalizeHeadersInput(headers, HeadersCtor);
60
+
61
+ return async (url, options = {}) => {
62
+ const mergedHeaders = mergeHeaders(defaultHeaders, options.headers, HeadersCtor);
63
+ return fetchImpl(url, { ...options, headers: mergedHeaders });
64
+ };
65
+ };
66
+
67
+ const loadHttpTransports = () => {
68
+ try {
69
+ const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js');
70
+ const { SSEClientTransport } = require('@modelcontextprotocol/sdk/client/sse.js');
71
+ return { StreamableHTTPClientTransport, SSEClientTransport };
72
+ } catch (error) {
73
+ console.log(chalk.yellow(`加载 HTTP MCP 传输失败:${error.message}`));
74
+ return {};
75
+ }
76
+ };
77
+
78
+ const buildHttpTransportOptions = (server) => {
79
+ const headers = normalizeHeaderValues(server && server.headers);
80
+ const options = {};
81
+
82
+ const fetchWithHeaders = createHttpFetch(headers);
83
+ if (fetchWithHeaders) {
84
+ options.fetch = fetchWithHeaders;
85
+ }
86
+
87
+ if (headers && Object.keys(headers).length > 0) {
88
+ options.headers = headers;
89
+ }
90
+
91
+ return options;
92
+ };
93
+
94
+ const getMcpConfigPath = () => getConfigPath('mcp');
95
+
96
+ const readConfigFile = (configPath) => {
97
+ try {
98
+ if (!fs.existsSync(configPath)) {
99
+ return null;
100
+ }
101
+ const raw = fs.readFileSync(configPath, 'utf8');
102
+ if (!raw.trim()) {
103
+ return null;
104
+ }
105
+ return JSON.parse(raw);
106
+ } catch (error) {
107
+ console.log(chalk.yellow(`读取 ${configPath} 失败:${error.message}`));
108
+ return null;
109
+ }
110
+ };
111
+
112
+ const normalizeServerEntry = (entryName, rawEntry = {}) => {
113
+ if (!isPlainObject(rawEntry)) {
114
+ return null;
115
+ }
116
+
117
+ const typeRaw = typeof rawEntry.type === 'string' ? rawEntry.type.trim().toLowerCase() : MCP_TYPE_STDIO;
118
+ const type = typeRaw === MCP_TYPE_HTTP ? MCP_TYPE_HTTP : MCP_TYPE_STDIO;
119
+
120
+ const name = typeof rawEntry.name === 'string' && rawEntry.name.trim() ? rawEntry.name.trim() : entryName;
121
+ const tools = Array.isArray(rawEntry.tools) ? rawEntry.tools.map(String).filter(Boolean) : null;
122
+
123
+ if (type === MCP_TYPE_HTTP) {
124
+ const urlRaw = typeof rawEntry.url === 'string' ? rawEntry.url.trim() : '';
125
+ if (!urlRaw) {
126
+ return null;
127
+ }
128
+
129
+ let parsedUrl;
130
+ try {
131
+ parsedUrl = new URL(urlRaw);
132
+ } catch (_) {
133
+ return null;
134
+ }
135
+
136
+ const headers = normalizeHeaderValues(rawEntry.headers);
137
+
138
+ return {
139
+ type: MCP_TYPE_HTTP,
140
+ name,
141
+ url: parsedUrl.toString(),
142
+ headers,
143
+ tools
144
+ };
145
+ }
146
+
147
+ const command = typeof rawEntry.command === 'string' ? rawEntry.command.trim() : '';
148
+ if (!command) {
149
+ return null;
150
+ }
151
+
152
+ const args = Array.isArray(rawEntry.args) ? rawEntry.args.map(String) : [];
153
+ const env = rawEntry.env && typeof rawEntry.env === 'object' ? rawEntry.env : {};
154
+ const cwd = typeof rawEntry.cwd === 'string' && rawEntry.cwd.trim() ? rawEntry.cwd.trim() : null;
155
+
156
+ return {
157
+ type: MCP_TYPE_STDIO,
158
+ name,
159
+ command,
160
+ args,
161
+ env,
162
+ cwd,
163
+ tools
164
+ };
165
+ };
166
+
167
+ const loadMcpServers = (allowedNames = null) => {
168
+ ensureConfigFiles();
169
+ const configPath = getMcpConfigPath();
170
+ const parsed = readConfigFile(configPath);
171
+ const servers = [];
172
+
173
+ if (Array.isArray(allowedNames) && allowedNames.length === 0) {
174
+ return { configPath, servers };
175
+ }
176
+
177
+ if (!parsed || typeof parsed !== 'object') {
178
+ return { configPath, servers };
179
+ }
180
+
181
+ const entries = parsed.mcpServers && typeof parsed.mcpServers === 'object'
182
+ ? parsed.mcpServers
183
+ : {};
184
+
185
+ Object.entries(entries).forEach(([key, value]) => {
186
+ const normalized = normalizeServerEntry(key, value);
187
+ if (!normalized) {
188
+ return;
189
+ }
190
+ if (Array.isArray(allowedNames)) {
191
+ if (allowedNames.length === 0) {
192
+ return;
193
+ }
194
+ if (!allowedNames.includes(normalized.name)) {
195
+ return;
196
+ }
197
+ }
198
+ servers.push(normalized);
199
+ });
200
+
201
+ return { configPath, servers };
202
+ };
203
+
204
+ const sanitizeName = (value, fallback) => {
205
+ const safe = String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '_');
206
+ return safe || fallback;
207
+ };
208
+
209
+ const buildToolCallName = (serverName, toolName) => {
210
+ // 构建带服务器前缀的工具名称,格式为 serverName_toolName
211
+ const prefix = sanitizeName(serverName, 'mcp');
212
+ const suffix = sanitizeName(toolName, 'tool');
213
+ const fullName = `${prefix}_${suffix}`;
214
+
215
+ if (fullName.length <= MAX_TOOL_NAME_LENGTH) {
216
+ return fullName;
217
+ }
218
+
219
+ // 如果名称过长,进行截断并添加哈希值以保证唯一性
220
+ const hash = crypto.createHash('md5').update(`${serverName}:${toolName}`).digest('hex').slice(0, 6);
221
+ const maxPrefixLength = Math.floor((MAX_TOOL_NAME_LENGTH - hash.length - 2) / 2);
222
+ const trimmedPrefix = prefix.slice(0, maxPrefixLength);
223
+ const trimmedSuffix = suffix.slice(0, MAX_TOOL_NAME_LENGTH - trimmedPrefix.length - hash.length - 2);
224
+ return `${trimmedPrefix}_${trimmedSuffix}_${hash}`;
225
+ };
226
+
227
+ const connectHttpClient = async (client, server) => {
228
+ const { StreamableHTTPClientTransport, SSEClientTransport } = loadHttpTransports();
229
+
230
+ if (!StreamableHTTPClientTransport && !SSEClientTransport) {
231
+ throw new Error('当前 SDK 版本不支持 HTTP 类型的 MCP 传输');
232
+ }
233
+
234
+ if (!server || !server.url) {
235
+ throw new Error('HTTP MCP 配置缺少 url');
236
+ }
237
+
238
+ let targetUrl;
239
+ try {
240
+ targetUrl = new URL(server.url);
241
+ } catch (_) {
242
+ throw new Error('HTTP MCP 配置的 url 无效');
243
+ }
244
+
245
+ const options = buildHttpTransportOptions(server);
246
+ const attempts = [];
247
+
248
+ if (StreamableHTTPClientTransport) {
249
+ attempts.push(() => new StreamableHTTPClientTransport(targetUrl, options));
250
+ }
251
+ if (SSEClientTransport) {
252
+ attempts.push(() => new SSEClientTransport(targetUrl, options));
253
+ }
254
+
255
+ let lastError = null;
256
+
257
+ for (const factory of attempts) {
258
+ const transport = factory();
259
+ try {
260
+ await client.connect(transport);
261
+ return transport;
262
+ } catch (error) {
263
+ lastError = error;
264
+ try {
265
+ await transport.close?.();
266
+ } catch (_) {
267
+ // ignore
268
+ }
269
+ }
270
+ }
271
+
272
+ throw lastError || new Error('HTTP MCP 连接失败');
273
+ };
274
+
275
+ const renderContentChunk = (chunk) => {
276
+ if (!chunk) return '';
277
+ if (typeof chunk === 'string') return chunk;
278
+ if (chunk.type === 'text' && typeof chunk.text === 'string') {
279
+ return chunk.text;
280
+ }
281
+ if (chunk.type === 'image' && chunk.data) {
282
+ return '[image content 已省略]';
283
+ }
284
+ if (chunk.type === 'resource' && chunk.uri) {
285
+ return `[resource] ${chunk.uri}`;
286
+ }
287
+ return (() => {
288
+ try {
289
+ return JSON.stringify(chunk, null, 2);
290
+ } catch (_) {
291
+ return String(chunk);
292
+ }
293
+ })();
294
+ };
295
+
296
+ const formatMcpContent = (content) => {
297
+ if (!content) return '';
298
+ if (Array.isArray(content)) {
299
+ return content
300
+ .map(renderContentChunk)
301
+ .filter(Boolean)
302
+ .join('\n---\n');
303
+ }
304
+ return renderContentChunk(content);
305
+ };
306
+
307
+ class McpManager {
308
+ constructor(workspaceRoot, allowedMcpNames = null) {
309
+ this.workspaceRoot = workspaceRoot;
310
+ this.allowedMcpNames = Array.isArray(allowedMcpNames) ? allowedMcpNames : null;
311
+ this.clients = [];
312
+ }
313
+
314
+ async initialize() {
315
+ const { configPath, servers } = loadMcpServers(this.allowedMcpNames);
316
+ this.configPath = configPath;
317
+
318
+ if (!servers || servers.length === 0) {
319
+ return;
320
+ }
321
+
322
+ for (const server of servers) {
323
+ await this.registerServer(server);
324
+ }
325
+ }
326
+
327
+ async registerServer(server) {
328
+ const client = new Client(
329
+ {
330
+ name: 'mini-cli',
331
+ version: pkgVersion
332
+ },
333
+ {
334
+ capabilities: {}
335
+ }
336
+ );
337
+
338
+ let transport = null;
339
+
340
+ try {
341
+ if (server.type === MCP_TYPE_HTTP) {
342
+ transport = await connectHttpClient(client, server);
343
+ } else {
344
+ const transportOptions = {
345
+ command: server.command,
346
+ args: server.args || [],
347
+ env: { ...process.env, ...server.env },
348
+ ...(server.cwd ? { cwd: server.cwd } : {}),
349
+ stderr: 'pipe'
350
+ };
351
+ transport = new StdioClientTransport(transportOptions);
352
+ await client.connect(transport);
353
+ }
354
+ } catch (error) {
355
+ console.log(chalk.yellow(`MCP 服务器 ${server.name} 连接失败:${error.message}`));
356
+ try {
357
+ await transport?.close?.();
358
+ } catch (_) {
359
+ // ignore close error
360
+ }
361
+ return;
362
+ }
363
+
364
+ const toolsResult = await (async () => {
365
+ try {
366
+ return await client.listTools();
367
+ } catch (error) {
368
+ console.log(chalk.yellow(`获取 MCP 工具失败 (${server.name}):${error.message}`));
369
+ return null;
370
+ }
371
+ })();
372
+
373
+ const tools = (toolsResult && Array.isArray(toolsResult.tools)) ? toolsResult.tools : [];
374
+
375
+ // 根据 server.tools 配置过滤工具
376
+ const filteredTools = server.tools && server.tools.length > 0
377
+ ? tools.filter(tool => server.tools.includes(tool.name))
378
+ : tools;
379
+
380
+ const registeredTools = filteredTools.map((tool) => {
381
+ const callName = buildToolCallName(server.name, tool.name);
382
+ const descriptionParts = [];
383
+ if (tool.description) {
384
+ descriptionParts.push(tool.description);
385
+ }
386
+ descriptionParts.push(`来源 MCP 服务器:${server.name}`);
387
+ const parameters = tool.inputSchema && typeof tool.inputSchema === 'object'
388
+ ? tool.inputSchema
389
+ : { type: 'object', properties: {} };
390
+ return {
391
+ name: callName,
392
+ isMcpTool: true, // 标记为 MCP 工具
393
+ schema: {
394
+ type: 'function',
395
+ function: {
396
+ name: callName,
397
+ description: descriptionParts.join(';'),
398
+ parameters
399
+ }
400
+ },
401
+ handler: async (args = {}) => {
402
+ try {
403
+ const result = await client.callTool(
404
+ {
405
+ name: tool.name,
406
+ arguments: args
407
+ },
408
+ undefined,
409
+ {
410
+ timeout: 600000 // 10 分钟超时
411
+ }
412
+ );
413
+ const isError = Boolean(result && result.isError);
414
+ const output = formatMcpContent(result && result.content);
415
+ if (isError) {
416
+ return output ? `MCP 工具返回错误:${output}` : 'MCP 工具返回错误';
417
+ }
418
+ return output || 'MCP 工具无输出';
419
+ } catch (error) {
420
+ return `调用 MCP 工具失败:${error.message}`;
421
+ }
422
+ }
423
+ };
424
+ });
425
+
426
+ this.clients.push({
427
+ server,
428
+ client,
429
+ transport,
430
+ tools: registeredTools
431
+ });
432
+ }
433
+
434
+ getTools() {
435
+ return this.clients.flatMap((entry) => entry.tools || []);
436
+ }
437
+
438
+ getConfigPath() {
439
+ return this.configPath;
440
+ }
441
+
442
+ getEnabledServerNames() {
443
+ return this.clients.map((entry) => entry.server.name);
444
+ }
445
+
446
+ async dispose() {
447
+ const closing = this.clients.map(async ({ client, transport }) => {
448
+ try {
449
+ await client.close?.();
450
+ } catch (_) {
451
+ // ignore
452
+ }
453
+ try {
454
+ await transport.close?.();
455
+ } catch (_) {
456
+ // ignore
457
+ }
458
+ });
459
+ await Promise.allSettled(closing);
460
+ }
461
+ }
462
+
463
+ const createMcpManager = async (workspaceRoot, allowedMcpNames = null) => {
464
+ if (Array.isArray(allowedMcpNames) && allowedMcpNames.length === 0) {
465
+ return null;
466
+ }
467
+ const manager = new McpManager(workspaceRoot, allowedMcpNames);
468
+ await manager.initialize();
469
+ const tools = manager.getTools();
470
+ if (!tools || tools.length === 0) {
471
+ return null;
472
+ }
473
+ return manager;
474
+ };
475
+
476
+ module.exports = {
477
+ createMcpManager
478
+ };
@@ -0,0 +1,100 @@
1
+ """Convert a single HTML file to PNG via Playwright screenshot."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from playwright.sync_api import sync_playwright
9
+
10
+
11
+ MIN_WIDTH = 320
12
+ MIN_HEIGHT = 240
13
+ DEVICE_SCALE_FACTOR = 4
14
+
15
+
16
+ def _find_target_element(page):
17
+ body = page.query_selector('body')
18
+ if body is None:
19
+ raise ValueError('未找到 body 元素')
20
+
21
+ top_level_elements = body.query_selector_all(':scope > *')
22
+ if not top_level_elements:
23
+ raise ValueError('body 中未找到可截图元素')
24
+
25
+ # 单个最外层节点时直接使用该节点,否则使用整个 body
26
+ return top_level_elements[0] if len(top_level_elements) == 1 else body
27
+
28
+
29
+ def _measure_element(element):
30
+ size = element.evaluate(
31
+ """
32
+ (el) => {
33
+ const rect = el.getBoundingClientRect();
34
+ const width = Math.max(rect.width || 0, el.scrollWidth || 0, el.offsetWidth || 0);
35
+ const height = Math.max(rect.height || 0, el.scrollHeight || 0, el.offsetHeight || 0);
36
+ return { width, height };
37
+ }
38
+ """
39
+ )
40
+ width = max(1, int(size.get('width') or 0))
41
+ height = max(1, int(size.get('height') or 0))
42
+ return width, height
43
+
44
+
45
+ def convert_html_to_png(input_file: str | Path, output_file: str | Path, wait_ms: int = 1500) -> Path:
46
+ """Convert a single HTML file to PNG image."""
47
+ html_path = Path(input_file).expanduser().resolve()
48
+ if not html_path.exists():
49
+ raise FileNotFoundError(f'HTML 文件不存在: {html_path}')
50
+
51
+ output_path = Path(output_file).expanduser().resolve()
52
+ output_path.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ with sync_playwright() as playwright:
55
+ browser = playwright.chromium.launch(headless=True)
56
+ context = browser.new_context(
57
+ viewport={'width': MIN_WIDTH, 'height': MIN_HEIGHT},
58
+ device_scale_factor=DEVICE_SCALE_FACTOR
59
+ )
60
+ page = context.new_page()
61
+ try:
62
+ file_url = html_path.as_uri()
63
+ page.goto(file_url)
64
+ page.wait_for_timeout(wait_ms)
65
+
66
+ target = _find_target_element(page)
67
+ element_width, element_height = _measure_element(target)
68
+ viewport_width = max(MIN_WIDTH, element_width)
69
+ viewport_height = max(MIN_HEIGHT, element_height)
70
+
71
+ page.set_viewport_size({'width': viewport_width, 'height': viewport_height})
72
+ page.wait_for_timeout(200)
73
+ target.screenshot(path=str(output_path))
74
+ finally:
75
+ context.close()
76
+ browser.close()
77
+
78
+ return output_path
79
+
80
+
81
+ def _parse_args() -> argparse.Namespace:
82
+ parser = argparse.ArgumentParser(description='将 HTML 文件转换为 PNG 图片。')
83
+ parser.add_argument('--input', required=True, help='HTML 文件路径')
84
+ parser.add_argument('--output', required=True, help='输出 PNG 文件路径')
85
+ parser.add_argument('--wait', type=int, default=1500, help='页面加载等待时间,单位毫秒,默认 1500')
86
+ return parser.parse_args()
87
+
88
+
89
+ def main() -> None:
90
+ args = _parse_args()
91
+ try:
92
+ result = convert_html_to_png(args.input, args.output, wait_ms=args.wait)
93
+ print(f'已生成 PNG: {result}')
94
+ except Exception as error:
95
+ print(f'转换失败: {error}', file=sys.stderr)
96
+ sys.exit(1)
97
+
98
+
99
+ if __name__ == '__main__':
100
+ main()