feat: 初始版本 - ttychart-mcp 终端图表 MCP 服务
- 支持三种图表: 折线图、柱状图、散点图 - MCP 协议支持 (stdio + HTTP) - 完整的单元测试和集成测试 - Docker 支持 - Makefile 构建脚本
This commit is contained in:
337
tools/provider.go
Normal file
337
tools/provider.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Package tools ttychart-mcp 工具实现包
|
||||
//
|
||||
// 该包提供所有 MCP 工具的具体实现,包括:
|
||||
// - plot_line: 折线图
|
||||
// - plot_bar: 柱状图
|
||||
// - plot_scatter: 散点图
|
||||
//
|
||||
// 作者: titor
|
||||
// 创建日期: 2026-04-15
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hub.gaomia.site/titor/ttychart-mcp/tools/charts"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// NewServer 创建新的 MCP 服务器
|
||||
func NewServer() *server.MCPServer {
|
||||
srv := server.NewMCPServer(
|
||||
"ttychart",
|
||||
"0.1.0",
|
||||
server.WithToolCapabilities(true),
|
||||
)
|
||||
|
||||
// 折线图工具
|
||||
plotLineTool := mcp.NewTool(
|
||||
"plot_line",
|
||||
mcp.WithDescription("绘制折线图,用于展示数据随时间或类别的变化趋势"),
|
||||
mcp.WithNumber("data",
|
||||
mcp.Required(),
|
||||
mcp.Description("Y轴数据点数组"),
|
||||
),
|
||||
mcp.WithString("title",
|
||||
mcp.Description("图表标题"),
|
||||
),
|
||||
mcp.WithNumber("width",
|
||||
mcp.Description("图表宽度(字符数),默认自动计算"),
|
||||
),
|
||||
mcp.WithNumber("height",
|
||||
mcp.Description("图表高度(行数),默认自动计算"),
|
||||
),
|
||||
mcp.WithString("labels",
|
||||
mcp.Description("X轴标签,多个标签用逗号分隔"),
|
||||
),
|
||||
mcp.WithString("color",
|
||||
mcp.Description("线条颜色,可选值: red, green, blue, yellow, cyan, magenta"),
|
||||
),
|
||||
)
|
||||
|
||||
// 柱状图工具
|
||||
plotBarTool := mcp.NewTool(
|
||||
"plot_bar",
|
||||
mcp.WithDescription("绘制柱状图,用于展示分类数据的比较"),
|
||||
mcp.WithString("data",
|
||||
mcp.Required(),
|
||||
mcp.Description("数据,格式: 标签1:数值1,标签2:数值2"),
|
||||
),
|
||||
mcp.WithString("title",
|
||||
mcp.Description("图表标题"),
|
||||
),
|
||||
mcp.WithNumber("width",
|
||||
mcp.Description("图表宽度"),
|
||||
),
|
||||
mcp.WithNumber("height",
|
||||
mcp.Description("图表高度"),
|
||||
),
|
||||
mcp.WithBoolean("horizontal",
|
||||
mcp.Description("是否为水平柱状图,默认为垂直"),
|
||||
),
|
||||
mcp.WithString("color",
|
||||
mcp.Description("柱体颜色"),
|
||||
),
|
||||
)
|
||||
|
||||
// 散点图工具
|
||||
plotScatterTool := mcp.NewTool(
|
||||
"plot_scatter",
|
||||
mcp.WithDescription("绘制散点图,用于展示数据的相关性"),
|
||||
mcp.WithString("data",
|
||||
mcp.Required(),
|
||||
mcp.Description("数据点,格式: x1,y1 x2,y2 ..."),
|
||||
),
|
||||
mcp.WithString("title",
|
||||
mcp.Description("图表标题"),
|
||||
),
|
||||
mcp.WithNumber("width",
|
||||
mcp.Description("图表宽度"),
|
||||
),
|
||||
mcp.WithNumber("height",
|
||||
mcp.Description("图表高度"),
|
||||
),
|
||||
mcp.WithString("x_label",
|
||||
mcp.Description("X轴标签"),
|
||||
),
|
||||
mcp.WithString("y_label",
|
||||
mcp.Description("Y轴标签"),
|
||||
),
|
||||
mcp.WithString("color",
|
||||
mcp.Description("点颜色"),
|
||||
),
|
||||
)
|
||||
|
||||
srv.AddTool(plotLineTool, handlePlotLineTool)
|
||||
srv.AddTool(plotBarTool, handlePlotBarTool)
|
||||
srv.AddTool(plotScatterTool, handlePlotScatterTool)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// handlePlotLineTool 处理折线图工具调用
|
||||
func handlePlotLineTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// 将请求参数转换为 JSON 再解析
|
||||
jsonBytes, err := json.Marshal(request.Params)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("参数解析失败"), err
|
||||
}
|
||||
|
||||
var args struct {
|
||||
Data interface{} `json:"data"`
|
||||
Title string `json:"title"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Labels string `json:"labels"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, &args); err != nil {
|
||||
return mcp.NewToolResultError("参数解析失败"), err
|
||||
}
|
||||
|
||||
// 处理数据
|
||||
var data []float64
|
||||
switch v := args.Data.(type) {
|
||||
case float64:
|
||||
data = []float64{v}
|
||||
case []interface{}:
|
||||
data = make([]float64, 0, len(v))
|
||||
for _, item := range v {
|
||||
if f, ok := toFloat64(item); ok {
|
||||
data = append(data, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return mcp.NewToolResultError("错误: data 参数无效"), nil
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
var labels []string
|
||||
if args.Labels != "" {
|
||||
labels = strings.Split(args.Labels, ",")
|
||||
}
|
||||
|
||||
// 获取颜色
|
||||
color := getColorStyle(args.Color)
|
||||
|
||||
// 生成图表
|
||||
graph := charts.NewLineChart(args.Width, args.Height)
|
||||
graph.SetData(data)
|
||||
graph.SetTitle(args.Title)
|
||||
graph.SetLabels(labels)
|
||||
graph.SetColor(color)
|
||||
|
||||
return mcp.NewToolResultText(graph.Render()), nil
|
||||
}
|
||||
|
||||
// handlePlotBarTool 处理柱状图工具调用
|
||||
func handlePlotBarTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
jsonBytes, err := json.Marshal(request.Params)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("参数解析失败"), err
|
||||
}
|
||||
|
||||
var args struct {
|
||||
Data interface{} `json:"data"`
|
||||
Title string `json:"title"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Horizontal bool `json:"horizontal"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, &args); err != nil {
|
||||
return mcp.NewToolResultError("参数解析失败"), err
|
||||
}
|
||||
|
||||
dataStr, ok := args.Data.(string)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("错误: data 参数格式无效"), nil
|
||||
}
|
||||
|
||||
// 解析数据: "标签1:数值1,标签2:数值2"
|
||||
data := parseBarData(dataStr)
|
||||
if len(data) == 0 {
|
||||
return mcp.NewToolResultError("错误: data 参数格式无效"), nil
|
||||
}
|
||||
|
||||
color := getColorStyle(args.Color)
|
||||
|
||||
graph := charts.NewBarChart(args.Width, args.Height, args.Horizontal)
|
||||
graph.SetData(data)
|
||||
graph.SetTitle(args.Title)
|
||||
graph.SetColor(color)
|
||||
|
||||
return mcp.NewToolResultText(graph.Render()), nil
|
||||
}
|
||||
|
||||
// handlePlotScatterTool 处理散点图工具调用
|
||||
func handlePlotScatterTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
jsonBytes, err := json.Marshal(request.Params)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("参数解析失败"), err
|
||||
}
|
||||
|
||||
var args struct {
|
||||
Data interface{} `json:"data"`
|
||||
Title string `json:"title"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
XLabel string `json:"x_label"`
|
||||
YLabel string `json:"y_label"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, &args); err != nil {
|
||||
return mcp.NewToolResultError("参数解析失败"), err
|
||||
}
|
||||
|
||||
dataStr, ok := args.Data.(string)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("错误: data 参数格式无效"), nil
|
||||
}
|
||||
|
||||
// 解析数据: "x1,y1 x2,y2 ..."
|
||||
data := parseScatterData(dataStr)
|
||||
if len(data) == 0 {
|
||||
return mcp.NewToolResultError("错误: data 参数格式无效"), nil
|
||||
}
|
||||
|
||||
color := getColorStyle(args.Color)
|
||||
|
||||
graph := charts.NewScatterChart(args.Width, args.Height)
|
||||
graph.SetData(data)
|
||||
graph.SetTitle(args.Title)
|
||||
graph.SetXLabel(args.XLabel)
|
||||
graph.SetYLabel(args.YLabel)
|
||||
graph.SetColor(color)
|
||||
|
||||
return mcp.NewToolResultText(graph.Render()), nil
|
||||
}
|
||||
|
||||
// parseBarData 解析柱状图数据
|
||||
// 格式: "标签1:数值1,标签2:数值2"
|
||||
func parseBarData(str string) []charts.BarDataItem {
|
||||
var result []charts.BarDataItem
|
||||
items := strings.Split(str, ",")
|
||||
for _, item := range items {
|
||||
parts := strings.SplitN(item, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
if value, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err == nil {
|
||||
result = append(result, charts.BarDataItem{
|
||||
Label: strings.TrimSpace(parts[0]),
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseScatterData 解析散点图数据
|
||||
// 格式: "x1,y1 x2,y2 ..."
|
||||
func parseScatterData(str string) []charts.ScatterDataItem {
|
||||
var result []charts.ScatterDataItem
|
||||
items := strings.Fields(str)
|
||||
for _, item := range items {
|
||||
parts := strings.SplitN(item, ",", 2)
|
||||
if len(parts) == 2 {
|
||||
x, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
y, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if err1 == nil && err2 == nil {
|
||||
result = append(result, charts.ScatterDataItem{
|
||||
X: x,
|
||||
Y: y,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getColorStyle 获取颜色样式
|
||||
func getColorStyle(colorName string) lipgloss.Style {
|
||||
switch colorName {
|
||||
case "red":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||
case "green":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
|
||||
case "blue":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
|
||||
case "yellow":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
|
||||
case "cyan":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("14"))
|
||||
case "magenta":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("13"))
|
||||
default:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat64 将 interface{} 转换为 float64
|
||||
func toFloat64(v interface{}) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// 这里添加 fmt 的使用以避免导入错误
|
||||
var _ = fmt.Sprintf
|
||||
Reference in New Issue
Block a user