AI书签管理工具开发全记录(十一):MCP集成

前言 📝

在上一篇文章中,我们实现了AI智能创建书签功能,大幅提升了用户操作效率。本文将聚焦于项目的另一个重要特性——MCP集成,让用户可以在支持mcp调用的客户端,例如cherry studio中对书签进行管理。

1. MCP介绍

1.1 MCP协议简介

MCP 是一种轻量级、跨平台的工具通信协议,简单的说就是大模型调用外部工具的一个统一规范,这里简要概括,后面单独写一篇文章详解。

1.2 工作流程

Client MCP Server AI Service {"tool":"get_url_suggestion", "params":{"url":"https://example.com"}} 分析网页内容 返回建议数据 {"result":{ "category":"技术", "name":"示例网站" }} Client MCP Server AI Service

2. MCP服务器实现 ⚡

2.1 MCP框架选择

官方并没有为go提供MCP的SDK。不过已经有mcp-go这样完善的第三方mcp框架了。

2.2 安装

go get github.com/mark3labs/mcp-go

看一下官方示例

package main
 
import (
    "context"
    "fmt"
 
    "github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
)
 
func main() {
    // Create a new MCP server
    s := server.NewMCPServer(
        "Demo 🚀",
        "1.0.0",
        server.WithToolCapabilities(false),
    )
 
    // Add tool
    tool := mcp.NewTool("hello_world",
        mcp.WithDescription("Say hello to someone"),
        mcp.WithString("name",
            mcp.Required(),
            mcp.Description("Name of the person to greet"),
        ),
    )
 
    // Add tool handler
    s.AddTool(tool, helloHandler)
 
    // Start the stdio server
    if err := server.ServeStdio(s); err != nil {
        fmt.Printf("Server error: %v\n", err)
    }
}
 
func helloHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    name, err := request.RequireString("name")
    if err != nil {
        return mcp.NewToolResultError(err.Error()), nil
    }
 
    return mcp.NewToolResultText(fmt.Sprintf("Hello, %s!", name)), nil
}

运行

go run main.go

这样一个简单的MCP服务器就启动起来了,并且可以通过stdio方式接受连接。

2.2 协议选择

目前mcp支持三种协议

  • stdio
  • SSE
  • streamable-HTTP
    目前stdio最常用,考虑到我们目前书签定位是本地使用。我们采用stdio方式启动。

2.4 完整代码

/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"regexp"
	"time"

	"github.com/ciclebyte/aibookmark/internal/common"
	"github.com/ciclebyte/aibookmark/internal/models"
	"github.com/ciclebyte/aibookmark/internal/utils"
	"github.com/cloudwego/eino-ext/components/model/openai"
	"github.com/cloudwego/eino/schema"
	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
	"github.com/spf13/cobra"
	"gorm.io/gorm"
)

// mcpCmd represents the mcp command
var mcpCmd = &cobra.Command{
	Use:   "mcp",
	Short: "启动MCP服务器",
	Long:  `启动MCP服务器(Stdio模式)`,
	Run: func(cmd *cobra.Command, args []string) {
		// 创建MCP服务器
		s := server.NewMCPServer(
			"AI书签助手 🚀",
			"1.0.0",
			server.WithToolCapabilities(false),
		)

		// 添加获取URL建议工具
		urlSuggestionTool := mcp.NewTool("get_url_suggestion",
			mcp.WithDescription("获取URL的建议信息"),
			mcp.WithString("url",
				mcp.Required(),
				mcp.Description("要分析的URL"),
			),
		)
		s.AddTool(urlSuggestionTool, urlSuggestionHandler)

		// 添加书签工具
		addBookmarkTool := mcp.NewTool("add_bookmark",
			mcp.WithDescription("添加新书签"),
			mcp.WithString("url",
				mcp.Required(),
				mcp.Description("书签URL"),
			),
			mcp.WithString("category",
				mcp.Description("书签分类"),
			),
			mcp.WithString("name",
				mcp.Description("书签名称"),
			),
			mcp.WithString("description",
				mcp.Description("书签描述"),
			),
		)
		s.AddTool(addBookmarkTool, addBookmarkHandler)

		// 启动Stdio服务器
		if err := server.ServeStdio(s); err != nil {
			log.Fatalf("服务器错误: %v\n", err)
		}
	},
}

func urlSuggestionHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	url, err := request.RequireString("url")
	if err != nil {
		return mcp.NewToolResultError(err.Error()), nil
	}

	if !utils.IsValidURL(url) {
		return mcp.NewToolResultError("无效的URL"), nil
	}

	// 获取配置
	config := common.AppConfigModel
	if config == nil {
		return mcp.NewToolResultError("无法获取配置"), nil
	}

	webpageInfo, err := utils.GetWebpageInfo(url)
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("获取网页信息失败: %v", err)), nil
	}

	// 获取数据库连接
	db, err := utils.GetGormDB()
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("获取数据库连接失败: %v", err)), nil
	}

	// 查询所有分类
	var categories []models.Category
	if err := db.Find(&categories).Error; err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("查询分类失败: %v", err)), nil
	}

	var categoryNames []string
	for _, cat := range categories {
		categoryNames = append(categoryNames, cat.Name)
	}

	// 初始化模型
	maxTokens := config.AI.MaxTokens
	temperature := float32(config.AI.Temperature)
	model, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{
		BaseURL:     config.AI.BaseURL,
		APIKey:      config.AI.PIKey,
		Timeout:     time.Duration(config.AI.Timeout) * time.Second,
		Model:       config.AI.Model,
		MaxTokens:   &maxTokens,
		Temperature: &temperature,
	})
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("初始化模型失败: %v", err)), nil
	}

	type Website struct {
		Category    string `json:"category"`
		Name        string `json:"name"`
		Description string `json:"description"`
	}

	// 准备消息
	messages := []*schema.Message{
		{
			Role: "system",
			Content: `你是一个专业的书签助手,负责分析网页内容并生成合适的书签信息。
请遵循以下规则:
1. 分类选择:
   - 分类名称应该简洁明了,尽量在10个字符以内,或者2-4个汉字
   - 避免使用测试、测试1等明显不合适的分类
   - 现有分类中没有合适的分类,优先创建新分类

2. 名称生成:
   - 使用网页标题作为基础,但要去除网站名称、分隔符等无关信息
   - 保持简洁,通常不超过10个汉字
   - 如果标题不够清晰,可以根据内容补充关键信息

3. 描述生成:
   - 总结网页的核心内容和价值
   - 突出最重要的2-3个要点
   - 使用简洁的语言,不超过50个汉字
   - 避免使用"这是一个..."等冗余表达
   - 使用markdown格式

请以JSON格式返回结果,格式如下:
{
    "category": "分类名称",
    "name": "书签名称",
    "description": "书签描述"
}`,
		},
		{
			Role: "user",
			Content: fmt.Sprintf(`请分析以下网页内容并生成书签信息:

现有分类列表:%v

网页信息:
标题:%s
内容:%s

请确保:
1. 避免使用测试、测试1等明显不合适的分类
2. 如果已经有适合的分类,不要创建重复的分类,例如已经有ai,就不要创建人工智能等分类
3. 生成的名称要简洁明了
4. 描述要突出网页的核心价值`, categoryNames, webpageInfo.Title, webpageInfo.Text),
		},
	}

	// 生成回复
	response, err := model.Generate(ctx, messages)
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("生成回复失败: %v", err)), nil
	}

	// 使用正则表达式去除 ```json 和 ```
	re := regexp.MustCompile("(?s)^\\s*```json\\s*(.*?)\\s*```\\s*$")
	matches := re.FindStringSubmatch(response.Content)
	if len(matches) < 2 {
		return mcp.NewToolResultError("无法提取JSON内容"), nil
	}
	cleanedJSON := matches[1]

	// 解析JSON到结构体
	var website Website
	err = json.Unmarshal([]byte(cleanedJSON), &website)
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("解析JSON时出错: %v", err)), nil
	}

	result := map[string]interface{}{
		"category":    website.Category,
		"name":        website.Name,
		"description": website.Description,
	}

	resultJSON, err := json.Marshal(result)
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("序列化结果失败: %v", err)), nil
	}

	return mcp.NewToolResultText(string(resultJSON)), nil
}

func addBookmarkHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	url, err := request.RequireString("url")
	if err != nil {
		return mcp.NewToolResultError(err.Error()), nil
	}

	if !utils.IsValidURL(url) {
		return mcp.NewToolResultError("无效的URL"), nil
	}

	category := request.GetString("category", "")
	name := request.GetString("name", "")
	description := request.GetString("description", "")

	// 获取数据库连接
	db, err := utils.GetGormDB()
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("获取数据库连接失败: %v", err)), nil
	}

	// 如果没有提供名称,使用URL的域名
	if name == "" {
		name = utils.TruncateURLForName(url)
	}

	// 如果没有提供分类,使用"未分类"
	if category == "" {
		category = "未分类"
	}

	// 查询或创建分类
	var categoryModel models.Category
	if err := db.Where("name = ?", category).First(&categoryModel).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			// 创建新分类
			categoryModel = models.Category{
				Name:        category,
				Description: category,
			}
			if err := db.Create(&categoryModel).Error; err != nil {
				return mcp.NewToolResultError(fmt.Sprintf("创建分类失败: %v", err)), nil
			}
		} else {
			return mcp.NewToolResultError(fmt.Sprintf("查询分类失败: %v", err)), nil
		}
	}

	// 检查书签是否已存在
	var existingBookmark models.Bookmark
	if err := db.Where("title = ? OR url = ?", name, url).First(&existingBookmark).Error; err == nil {
		return mcp.NewToolResultError("书签已存在"), nil
	}

	// 创建新书签
	bookmark := models.Bookmark{
		Title:       name,
		URL:         url,
		Description: description,
		CategoryID:  categoryModel.ID,
	}

	if err := db.Create(&bookmark).Error; err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("创建书签失败: %v", err)), nil
	}

	result := map[string]interface{}{
		"message": fmt.Sprintf("书签 %s 添加成功", url),
		"bookmark": map[string]interface{}{
			"id":          bookmark.ID,
			"title":       bookmark.Title,
			"url":         bookmark.URL,
			"description": bookmark.Description,
			"category":    category,
		},
	}

	resultJSON, err := json.Marshal(result)
	if err != nil {
		return mcp.NewToolResultError(fmt.Sprintf("序列化结果失败: %v", err)), nil
	}

	return mcp.NewToolResultText(string(resultJSON)), nil
}

func init() {
	rootCmd.AddCommand(mcpCmd)
}

这样,我们就对外暴露了两个工具:
image.png
可以让mcp客户端进行调用

3. MCP客户端配置 🔧

这里我们以cherry studio为例,介绍一下如何配置已经使用我们刚刚编写的mcp服务器。
由于这里涉及到打包,我们假定已经打包好了exe文件。

3.1 配置MCP

点击配置-MCP服务器-添加服务器

image.png

也可以编辑mcp配置文件

{
  "mcpServers": {
    "NCweVcwgsRqRDrRlrdq9R": {
      "name": "AiBookmark",
      "type": "stdio",
      "description": "AiBookmark",
      "isActive": true,
      "command": "aibookmark.exe mcp",
      "args": []
    }
  }
}

如果aibookmark没有添加到系统环境变量中,需要指定完整路径。

3.2 使用MCP

在聊天框启用mcp,勾选上刚刚配置的mcp

image.png

接下来就可以像聊天一样添加书签了

image.png


往期系列

Logo

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。

更多推荐