lcp-chart-mcp 0.1.0__py2.py3-none-any.whl

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,4 @@
1
+ """
2
+ 生成圖表 MCP server
3
+ """
4
+ __version__ = "0.1.0"
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ QuickChart MCP 伺服器 - 可讀 URL + 固定 Chart.js v2 版 (精簡與優化版)
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ from copy import deepcopy
10
+ from typing import Dict, Any, List, Optional
11
+ from urllib.parse import quote
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ # 初始化 MCP 伺服器
15
+ mcp = FastMCP("QuickChartURL")
16
+
17
+ # QuickChart 服務位置
18
+ QUICKCHART_HOST = os.getenv("QUICKCHART_HOST", "localhost")
19
+ QUICKCHART_PORT = os.getenv("QUICKCHART_PORT", "3400")
20
+ QUICKCHART_BASE_URL = f"http://{QUICKCHART_HOST}:{QUICKCHART_PORT}"
21
+
22
+ # 對外可訪問的 URL(例如瀏覽器)
23
+ QUICKCHART_EXTERNAL_URL = os.getenv("QUICKCHART_EXTERNAL_URL", f"http://localhost:{QUICKCHART_PORT}")
24
+
25
+ # 固定 Chart.js 版本:v2
26
+ DEFAULT_CHART_VERSION = "2"
27
+
28
+ # ---------------------------------------------------------
29
+ # 可讀 URL(JS 物件字串)序列化工具
30
+ # ---------------------------------------------------------
31
+
32
+ SAFE_KEY_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
33
+
34
+ def _escape_js_str(s: str) -> str:
35
+ return s.replace('\\', '\\\\').replace("'", "\\'")
36
+
37
+ def to_js_object(value) -> str:
38
+ """將 Python 對象轉換為 JavaScript 對象字符串,正確處理 JavaScript 函數。"""
39
+ if isinstance(value, dict):
40
+ parts = []
41
+ for k, v in value.items():
42
+ key = k if SAFE_KEY_RE.match(k) else f"'{_escape_js_str(k)}'"
43
+ parts.append(f"{key}:{to_js_object(v)}")
44
+ return "{" + ",".join(parts) + "}"
45
+ elif isinstance(value, list):
46
+ return "[" + ",".join(to_js_object(x) for x in value) + "]"
47
+ elif isinstance(value, str):
48
+ # ★ 關鍵修復:檢查是否為 JavaScript 函數
49
+ if value.strip().startswith('function'):
50
+ return value # JavaScript 函數直接返回,不加引號
51
+ return "'" + _escape_js_str(value) + "'"
52
+ elif isinstance(value, bool):
53
+ return "true" if value else "false"
54
+ elif value is None:
55
+ return "null"
56
+ else:
57
+ return str(value)
58
+
59
+ def deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
60
+ if a is None: a = {}
61
+ if b is None: return deepcopy(a)
62
+ result = deepcopy(a)
63
+ for k, v in b.items():
64
+ if isinstance(v, dict) and isinstance(result.get(k), dict):
65
+ result[k] = deep_merge(result[k], v)
66
+ else:
67
+ result[k] = deepcopy(v)
68
+ return result
69
+
70
+ # ---------------------------------------------------------
71
+ # 圖表輔助函式
72
+ # ---------------------------------------------------------
73
+
74
+ BASE_COLORS = [
75
+ 'rgba(255, 99, 132, 0.8)', 'rgba(54, 162, 235, 0.8)', 'rgba(255, 206, 86, 0.8)',
76
+ 'rgba(75, 192, 192, 0.8)', 'rgba(153, 102, 255, 0.8)', 'rgba(255, 159, 64, 0.8)',
77
+ 'rgba(199, 199, 199, 0.8)', 'rgba(83, 102, 255, 0.8)', 'rgba(255, 99, 255, 0.8)',
78
+ 'rgba(99, 255, 132, 0.8)',
79
+ ]
80
+
81
+ def get_color_by_index(index: int) -> str:
82
+ return BASE_COLORS[index % len(BASE_COLORS)]
83
+
84
+ def rgba_with_alpha(rgba: str, alpha: float) -> str:
85
+ try:
86
+ inner = rgba.split('rgba(')[1].split(')')[0]
87
+ parts = [p.strip() for p in inner.split(',')]
88
+ if len(parts) == 4: parts[-1] = str(alpha)
89
+ return f"rgba({','.join(parts)})"
90
+ except Exception: pass
91
+ return rgba
92
+
93
+ def is_pie_like(chart_type: str) -> bool:
94
+ return chart_type in ['pie', 'doughnut', 'polarArea']
95
+
96
+ def normalize_datasets(chart_type: str, labels: List[Any], datasets: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
97
+ norm = []
98
+ for i, ds in enumerate(datasets or []):
99
+ ds = deepcopy(ds)
100
+ if is_pie_like(chart_type) or chart_type == 'radar':
101
+ if "backgroundColor" not in ds:
102
+ if chart_type == 'radar':
103
+ base = get_color_by_index(i)
104
+ ds["borderColor"] = ds.get("borderColor", base)
105
+ ds["backgroundColor"] = ds.get("backgroundColor", rgba_with_alpha(base, 0.2))
106
+ else:
107
+ count = len(ds.get("data", [])) or len(labels or [])
108
+ ds["backgroundColor"] = [get_color_by_index(j) for j in range(count)]
109
+ else:
110
+ if "backgroundColor" not in ds:
111
+ base = get_color_by_index(i)
112
+ if (ds.get("type") or chart_type) == "line":
113
+ ds["borderColor"] = ds.get("borderColor", base)
114
+ ds["backgroundColor"] = ds.get("backgroundColor", rgba_with_alpha(base, 0.2))
115
+ ds["fill"] = ds.get("fill", False)
116
+ ds["tension"] = ds.get("tension", 0.3)
117
+ ds["pointRadius"] = ds.get("pointRadius", 3)
118
+ else:
119
+ ds["backgroundColor"] = base
120
+ norm.append(ds)
121
+ return norm
122
+
123
+ def add_title_options(options: Dict[str, Any], title: Optional[str]) -> Dict[str, Any]:
124
+ if not title: return options or {}
125
+ opts = deepcopy(options) if options else {}
126
+ opts.setdefault("title", {"display": True, "text": title})
127
+ opts.setdefault("plugins", {}).setdefault("title", {"display": True, "text": title})
128
+ opts.setdefault("responsive", False)
129
+ return opts
130
+
131
+ def add_padding_options(chart_type: str, options: Dict[str, Any]) -> Dict[str, Any]:
132
+ opts = deepcopy(options) if options else {}
133
+ padding_needed = 0
134
+ if is_pie_like(chart_type): padding_needed = 40
135
+ elif chart_type in ['radialGauge']: padding_needed = 25
136
+
137
+ if padding_needed > 0:
138
+ padding_config = {"top": padding_needed, "right": padding_needed, "bottom": padding_needed, "left": padding_needed}
139
+ opts.setdefault("layout", {})["padding"] = padding_config
140
+ return opts
141
+
142
+ def ensure_plugins(options: Dict[str, Any]) -> Dict[str, Any]:
143
+ opts = deepcopy(options) if options else {}
144
+ opts.setdefault("plugins", {})
145
+ return opts
146
+
147
+ def merge_plugin_config(options: Dict[str, Any], plugins_config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
148
+ if not plugins_config: return options or {}
149
+ opts = ensure_plugins(options)
150
+ opts["plugins"] = deep_merge(opts.get("plugins", {}), plugins_config)
151
+ return opts
152
+
153
+ def build_chart_url_from_config(
154
+ config: Dict[str, Any], width: int, height: int, fmt: str = "png",
155
+ background_color: Optional[str] = None, encoding_mode: str = "js",
156
+ ) -> str:
157
+ base = f"{QUICKCHART_EXTERNAL_URL}/chart"
158
+ if encoding_mode == "js":
159
+ c_str = to_js_object(config)
160
+ encoded_chart = quote(c_str, safe="{}[],:()'")
161
+ else:
162
+ chart_json = json.dumps(config, separators=(",", ":"))
163
+ encoded_chart = quote(chart_json)
164
+
165
+ url = f"{base}?c={encoded_chart}&width={width}&height={height}&v={quote(DEFAULT_CHART_VERSION)}"
166
+ if fmt in ("png", "svg"): url += f"&format={fmt}"
167
+ if background_color: url += f"&backgroundColor={quote(background_color)}"
168
+ return url
169
+
170
+ # ---------------------------------------------------------
171
+ # MCP 工具 (核心工具)
172
+ # ---------------------------------------------------------
173
+
174
+ @mcp.tool()
175
+ def create_chart_url(
176
+ title: str, chart_type: str = "bar", labels: list = None, datasets: list = None,
177
+ width: int = 500, height: int = 300, options: dict = None, raw_config: dict = None,
178
+ format: str = "png", background_color: str = None, enable_plugins: list = None, plugins_config: dict = None,
179
+ ) -> str:
180
+ """核心圖表生成函式,建立並回傳一個 QuickChart 圖表的 Markdown 格式 URL。"""
181
+ if raw_config:
182
+ chart_config = deepcopy(raw_config)
183
+ chart_config["options"] = add_title_options(chart_config.get("options"), title)
184
+ else:
185
+ chart_config = {
186
+ "type": chart_type,
187
+ "data": {"labels": labels or [], "datasets": normalize_datasets(chart_type, labels or [], datasets or [])},
188
+ "options": add_title_options(options, title),
189
+ }
190
+
191
+ current_chart_type = chart_config.get("type", chart_type)
192
+ chart_config["options"] = add_padding_options(current_chart_type, chart_config.get("options"))
193
+
194
+ if enable_plugins or plugins_config:
195
+ chart_config["options"] = ensure_plugins(chart_config.get("options"))
196
+ quick_plugins = {}
197
+ for p in (enable_plugins or []):
198
+ if p == "datalabels": quick_plugins.setdefault("datalabels", {"anchor": "end", "align": "top"})
199
+ elif p == "annotation": quick_plugins.setdefault("annotation", {})
200
+ elif p == "outlabels": quick_plugins.setdefault("outlabels", {"text": "%l %p", "color": "white", "stretch": 15})
201
+ elif p == "doughnutlabel": quick_plugins.setdefault("doughnutlabel", {})
202
+ elif p == "colorschemes": quick_plugins.setdefault("colorschemes", {"scheme": "brewer.SetTwo8"})
203
+
204
+ if quick_plugins: chart_config["options"] = merge_plugin_config(chart_config["options"], quick_plugins)
205
+ if plugins_config: chart_config["options"] = merge_plugin_config(chart_config["options"], plugins_config)
206
+
207
+ url = build_chart_url_from_config(
208
+ config=chart_config, width=width, height=height,
209
+ fmt=format, background_color=background_color,
210
+ )
211
+ return f"![{title}]({url})\n\n[在新分頁開啟]({url})"
212
+
213
+ # ---------------------------------------------------------
214
+ # MCP 工具 (便利工具)
215
+ # ---------------------------------------------------------
216
+
217
+ @mcp.tool()
218
+ def create_comparison_chart_url(
219
+ title: str, chart_type: str, categories: list, series_data: dict,
220
+ width: int = 600, height: int = 400, format: str = "png",
221
+ background_color: str = None, enable_plugins: list = None, plugins_config: dict = None,
222
+ ) -> str:
223
+ """建立一個比較多個系列的圖表(長條圖、折線圖等)。"""
224
+ datasets = [{"label": series_name, "data": data_values} for series_name, data_values in series_data.items()]
225
+ return create_chart_url(
226
+ title=title, chart_type=chart_type, labels=categories, datasets=datasets,
227
+ width=width, height=height, format=format, background_color=background_color,
228
+ enable_plugins=enable_plugins, plugins_config=plugins_config,
229
+ )
230
+
231
+ @mcp.tool()
232
+ def create_mixed_bar_line_url(
233
+ title: str, labels: list, bar_series: dict, line_series: dict,
234
+ width: int = 700, height: int = 400, stacked: bool = False,
235
+ format: str = "png", background_color: str = None,
236
+ ) -> str:
237
+ """建立一個混合長條圖和折線圖的圖表。"""
238
+ datasets = []
239
+ for name, data in bar_series.items(): datasets.append({"type": "bar", "label": name, "data": data, "yAxisID": "y-axis-1"})
240
+ for name, data in line_series.items(): datasets.append({"type": "line", "label": name, "data": data, "fill": False, "yAxisID": "y-axis-1"})
241
+
242
+ options = { "scales": { "xAxes": [{"stacked": stacked}], "yAxes": [{"id": "y-axis-1", "stacked": stacked, "ticks": {"beginAtZero": True}}] } }
243
+ normalized_datasets = normalize_datasets("bar", labels, datasets)
244
+ return create_chart_url(
245
+ title=title, chart_type="bar", labels=labels, datasets=normalized_datasets,
246
+ options=options, width=width, height=height, format=format, background_color=background_color,
247
+ )
248
+
249
+ @mcp.tool()
250
+ def create_pie_outlabels_url(
251
+ title: str, labels: list, values: list, width: int = 500, height: int = 500,
252
+ format: str = "png", background_color: str = None,
253
+ ) -> str:
254
+ """建立一個帶有外部標籤的甜甜圈圖。"""
255
+ datasets = [{"data": values}]
256
+ plugins_config = { "outlabels": {"text": "%l %p", "color": "white", "stretch": 35, "font": {"resizable": True, "minSize": 12}, "lineColor": "#999"} }
257
+ return create_chart_url(
258
+ title=title, chart_type="doughnut", labels=labels, datasets=datasets,
259
+ width=width, height=height, format=format, background_color=background_color, plugins_config=plugins_config,
260
+ )
261
+
262
+ # --- MODIFIED ---
263
+ @mcp.tool()
264
+ def create_progress_circle_url(
265
+ title: str, value: float, max_value: float = 100.0, width: int = 500, height: int = 300,
266
+ format: str = "png", background_color: str = None, color: str = "rgba(54, 162, 235, 0.8)",
267
+ ) -> str:
268
+ """建立一個顯示進度的圓圈圖,中心會顯示百分比。"""
269
+ # JS 函式字串,用於在中心顯示百分比
270
+ # 使用 f-string 將 max_value 注入,並用 {{}} 來轉義 JS 的 {}
271
+ center_text_func = f"function(value) {{ return Math.round(value / {max_value} * 100) + '%'; }}"
272
+
273
+ config = {
274
+ "type": "radialGauge",
275
+ "data": {"datasets": [{"data": [max(0, min(max_value, value))], "backgroundColor": color, "borderWidth": 0}]},
276
+ "options": {
277
+ "domain": [0, max_value],
278
+ "trackColor": "#eee",
279
+ "centerPercentage": 80,
280
+ "roundedCorners": True,
281
+ # 在中心顯示百分比
282
+ "centerArea": {
283
+ "text": center_text_func
284
+ }
285
+ }
286
+ }
287
+ return create_chart_url(
288
+ title=title, chart_type="radialGauge", raw_config=config,
289
+ width=width, height=height, format=format, background_color=background_color,
290
+ )
291
+
292
+
293
+
294
+ @mcp.tool()
295
+ def create_radar_chart_url(
296
+ title: str, categories: list, series_data: dict,
297
+ width: int = 500, height: int = 500, format: str = "png", background_color: str = None
298
+ ) -> str:
299
+ """建立一個雷達圖(蜘蛛網圖)來比較多維度數據。"""
300
+ return create_comparison_chart_url(
301
+ title=title, chart_type="radar", categories=categories, series_data=series_data,
302
+ width=width, height=height, format=format, background_color=background_color
303
+ )
304
+
305
+ @mcp.tool()
306
+ def create_qr_url(
307
+ text: str, size: int = 150, margin: int = 4, ecLevel: str = "M",
308
+ dark: str = "000000", light: str = "ffffff", format: str = "png",
309
+ ) -> str:
310
+ """建立一個 QR Code 圖片。"""
311
+ base = f"{QUICKCHART_EXTERNAL_URL}/qr"
312
+ params = [
313
+ f"text={quote(text)}", f"format={format}", f"size={size}",
314
+ f"margin={margin}", f"ecLevel={ecLevel}", f"dark={dark}", f"light={light}",
315
+ ]
316
+ url = f"{base}?{'&'.join(params)}"
317
+ return f"![QR Code for {text}]({url})\n\n[在新分頁開啟]({url})"
318
+
319
+ # ---------------------------------------------------------
320
+ # Prompt & Server Main
321
+ # ---------------------------------------------------------
322
+
323
+ @mcp.prompt()
324
+ def chart_prompt(data_description: str) -> str:
325
+ # --- MODIFIED ---
326
+ return f"""
327
+ 請根據以下描述生成圖表 URL:「{data_description}」
328
+ 主要工具:
329
+ - `create_chart_url()`: 通用圖表 (長條、折線等)
330
+ - `create_comparison_chart_url()`: 比較多個系列
331
+ - `create_mixed_bar_line_url()`: 混合長條圖和折線圖
332
+ - `create_pie_outlabels_url()`: 帶外部標籤的甜甜圈圖
333
+ - `create_progress_circle_url()`: 進度圓圈圖 (中心顯示百分比)
334
+ - `create_radar_chart_url()`: 雷達圖/蜘蛛網圖
335
+
336
+ 若需更進階,請使用 `create_chart_url` 的 `raw_config` 與 `plugins_config`。
337
+ 所有圖表固定使用 Chart.js v2。
338
+ """
339
+
340
+ def main():
341
+ import sys
342
+ import logging
343
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
344
+ logger = logging.getLogger(__name__)
345
+ logger.info("啟動 QuickChart URL MCP 伺服器(可讀 URL + v2 固定)")
346
+ logger.info(f"QuickChart 服務: {QUICKCHART_BASE_URL}")
347
+ logger.info(f"外部訪問 URL: {QUICKCHART_EXTERNAL_URL}")
348
+ logger.info(f"固定 Chart.js 版本: {DEFAULT_CHART_VERSION}")
349
+ try:
350
+ mcp.run()
351
+ except KeyboardInterrupt:
352
+ logger.info("伺服器已停止")
353
+ sys.exit(0)
354
+ except Exception as e:
355
+ logger.error(f"伺服器錯誤: {str(e)}", exc_info=True)
356
+ sys.exit(1)
357
+
358
+ if __name__ == "__main__":
359
+ main()
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: lcp-chart-mcp
3
+ Version: 0.1.0
4
+ Summary: 一個 MCP 伺服器,提供圖表生成工具
5
+ Requires-Dist: httpx
6
+ Requires-Dist: mcp
@@ -0,0 +1,6 @@
1
+ lcp_chart_mcp/__init__.py,sha256=G1mffWXDLft7q0jAe_HDOkz_yvqLb1gCzXzXoAhHchI,56
2
+ lcp_chart_mcp/server.py,sha256=CIs6RMBuqwQMT-vEPUFODuCTxkPLQb2vG5FX2NE15LQ,15835
3
+ lcp_chart_mcp-0.1.0.dist-info/METADATA,sha256=J4vEiqpA0mfht5GPXuU-yI29ExWQvM_vkkywR8AOfVw,154
4
+ lcp_chart_mcp-0.1.0.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
5
+ lcp_chart_mcp-0.1.0.dist-info/entry_points.txt,sha256=1PXWn8XCRkJQbRD5WS5MHu3grsda3_KtTInM9mBt6ZE,60
6
+ lcp_chart_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lcp-chart-mcp = lcp_chart_mcp.server:main