让 Cursor 教我写 MCP Server

170 阅读6分钟

1. 写在最前面

「纸上得来终觉浅,绝知此事要躬行」在研究如何写 mcp server 的时候,脑子里突然蹦出来这么一句诗。

五一假期前趁着有空,浅浅的了解了一下关于 MCP 的概念,但是想要真正的理解,总是靠看别人的文章,应该没有办法对 mcp 的协议有更深刻的认知。

MCP 的核心概念有三个:

Hosts: 发起连接的 LLM 应用程序,比如 Claude Desktop 或 IDE。

Clients: 在 Hosts 应用程序内部与 Servers 保持一对一的连接。

Servers: 向 Clients 提供上下文、工具和提示。

注:柿子要挑软的捏,骨头要挑硬的啃,那就先从实现一个 MCP Server 开始吧。

2. 动手实现一个 MCP Server

讲真我真的在 google 上查了半天试图找到一个最简单的介绍说明示例,然后照着其步骤动手实践,奈何好像似乎大家写的都过于深奥了,作为一个 MCP Server 的小白,我还是没有办法立刻理解大佬们的思路。

注:知识这个东西,别人总结的总归是别人的,只有自己真的动手实践过的知识才真的是自己的,哈哈哈,当代阿 Q 第一人。

2.1 Why 天气查询

原因简单说明:

  • cursor 是支持直接调用 MCP Server 的,这样就省去自己实现 Hosts 和 clients 的步骤。
  • cursor 的 「@web 」功能已经支持了实时联网查询,可以支持搜索天气,但是毕竟是自己学习理解的过程,选择实现一个查询天气的 MCP Server ,应该不算过分的偷工减料。

注:大模型是不支持实时信息的查询的,cursor 支持是它作为 IDE ,内置集成了很多能力。

请求大模型查询天气的示例:

企业微信截图_55456ed8-5092-451f-a927-b132300595cc.png

2.2  What 天气查询

让我们采用倒序的手法,展示一下两种天气查询的方式的效果:

  • Cursor 内置的 @web 的能力

企业微信截图_46a9f3aa-b9a3-437b-abe5-219bef70f97a.png

  • 让 cursor 主动调用 mcp server

企业微信截图_8da91790-215e-4d33-86b6-7770016a97a8.png

2.3 How 天气查询

2.3.1 配置 MCP Server

配置示例:

{
  "mcpServers": {
    "weather": {
      "type": "sse",
      "url": "http://localhost:8080/sse",
      "description": "查询天气信息",
      "tools": [
        {
          "name": "get_weather",
          "description": "获取指定城市的天气信息",
          "parameters": {
            "city": {
              "type": "string",
              "description": "城市名称",
              "default": "北京"
            }
          }
        }
      ]
    }
  }
}

请求方式:此步骤需要切换请求的新增的 MCP Server

企业微信截图_7b878e1f-f5e8-4585-aa06-ddc94b1e7225.png

注:请求的方式随着 cursor 编辑器的迭代和更新可能会有改变,所以以使用 cursor 编辑器版本为准。

笔者在找请求的方式的时候,也是找了很久才找到的,因为网上大部分的文章都是说要使用 MCP:call server

2.3.2 实现 MCP Server

不要过于神话你不理解的知识或者人,其实世界就是一个巨大的草台班子。

极简版本的 MCP Server 只实现了三个 Method:

image.png

完整 go 代码:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "time"

    "github.com/gin-gonic/gin"
)

// MCPResponse 定义 MCP 响应格式
type MCPResponse struct {
    Type    string      `json:"type"`
    Content interface{} `json:"content"`
}

// MCPToolResponse 定义工具响应格式
type MCPToolResponse struct {
    Name       string      `json:"name"`
    Parameters interface{} `json:"parameters,omitempty"`
    Response   interface{} `json:"response,omitempty"`
    Error      string      `json:"error,omitempty"`
}

func main() {
    r := gin.Default()

    // 添加 CORS 中间件
    r.Use(func(c *gin.Context) {
        c.Header("Access-Control-Allow-Origin", "*")
        c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept")
        c.Header("Access-Control-Expose-Headers", "Content-Type")

        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(204)
            return
        }

        c.Next()
    })

    // 添加 SSE 端点
    r.GET("/sse", func(c *gin.Context) {
        log.Printf("收到 SSE 连接请求")

        // 设置 SSE 相关的 headers
        c.Header("Content-Type", "text/event-stream")
        c.Header("Cache-Control", "no-cache")
        c.Header("Connection", "keep-alive")
        c.Header("X-Accel-Buffering", "no")
        c.Header("Access-Control-Allow-Origin", "*")
        c.Header("Access-Control-Allow-Headers", "Content-Type")
        c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")

        // 清除之前的任何缓存
        c.Writer.Flush()

        // 创建一个通道用于发送心跳
        ticker := time.NewTicker(30 * time.Second)
        defer ticker.Stop()

        // 发送初始连接成功消息
        err := writeSSEEvent(c.Writer, "ready", MCPResponse{
            Type: "ready",
            Content: map[string]interface{}{
                "status": "connected",
                "tools": []map[string]interface{}{
                    {
                        "name":        "get_weather",
                        "description": "获取指定城市的天气信息",
                        "parameters": map[string]interface{}{
                            "city": map[string]interface{}{
                                "type":        "string",
                                "description": "城市名称",
                                "default":     "北京",
                            },
                        },
                    },
                },
            },
        })

        if err != nil {
            log.Printf("发送初始消息失败:%v", err)
            return
        }

        // 保持连接并发送心跳
        for {
            select {
            case <-ticker.C:
                err := writeSSEEvent(c.Writer, "ping", map[string]string{"type": "ping"})
                if err != nil {
                    log.Printf("发送心跳失败:%v", err)
                    return
                }
            case <-c.Request.Context().Done():
                log.Printf("客户端断开连接")
                return
            }
        }
    })

    // 天气查询接口
    r.POST("/sse/invoke", func(c *gin.Context) {
        // 设置 CORS 和 SSE 相关的 headers
        c.Header("Access-Control-Allow-Origin", "*")
        c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        c.Header("Access-Control-Allow-Headers", "Content-Type")
        c.Header("Content-Type", "application/json")

        var request struct {
            Tool       string                 `json:"tool"`
            Parameters map[string]interface{} `json:"parameters"`
        }

        if err := c.BindJSON(&request); err != nil {
            c.JSON(http.StatusBadRequest, MCPToolResponse{
                Name:  request.Tool,
                Error: fmt.Sprintf("无效的请求格式:%v", err),
            })
            return
        }

        if request.Tool != "get_weather" {
            c.JSON(http.StatusBadRequest, MCPToolResponse{
                Name:  request.Tool,
                Error: "不支持的工具",
            })
            return
        }

        city, _ := request.Parameters["city"].(string)
        if city == "" {
            city = "北京"
        }

        // 构建 wttr.in 的 URL
        baseURL := "https://wttr.in/%s?format=j1&lang=zh"
        encodedCity := url.QueryEscape(city)
        wttrURL := fmt.Sprintf(baseURL, encodedCity)

        // 发送请求
        client := &http.Client{}
        req, err := http.NewRequest("GET", wttrURL, nil)
        if err != nil {
            c.JSON(http.StatusInternalServerError, MCPToolResponse{
                Name:  request.Tool,
                Error: fmt.Sprintf("创建请求失败:%v", err),
            })
            return
        }

        req.Header.Set("User-Agent", "curl/7.64.1")
        resp, err := client.Do(req)
        if err != nil {
            c.JSON(http.StatusInternalServerError, MCPToolResponse{
                Name:  request.Tool,
                Error: fmt.Sprintf("获取天气数据失败:%v", err),
            })
            return
        }
        defer resp.Body.Close()

        body, err := io.ReadAll(resp.Body)
        if err != nil {
            c.JSON(http.StatusInternalServerError, MCPToolResponse{
                Name:  request.Tool,
                Error: fmt.Sprintf("读取响应失败:%v", err),
            })
            return
        }

        var weatherData interface{}
        if err := json.Unmarshal(body, &weatherData); err != nil {
            c.JSON(http.StatusInternalServerError, MCPToolResponse{
                Name:  request.Tool,
                Error: fmt.Sprintf("解析天气数据失败:%v", err),
            })
            return
        }

        c.JSON(http.StatusOK, MCPToolResponse{
            Name:       request.Tool,
            Parameters: request.Parameters,
            Response:   weatherData,
        })
    })

    // 添加健康检查接口
    r.GET("/health", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "status": "ok",
        })
    })

    log.Printf("服务器启动在 http://localhost:8080")
    r.Run(":8080")
}

func writeSSEEvent(w http.ResponseWriter, event string, data interface{}) error {
    jsonData, err := json.Marshal(data)
    if err != nil {
        log.Printf("序列化 SSE 数据失败:%v", err)
        return fmt.Errorf("序列化 SSE 数据失败:%v", err)
    }

    log.Printf("发送 SSE 事件:%s,数据:%s", event, string(jsonData))

    if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, jsonData); err != nil {
        log.Printf("写入 SSE 数据失败:%v", err)
        return fmt.Errorf("写入 SSE 数据失败:%v", err)
    }

    if f, ok := w.(http.Flusher); ok {
        f.Flush()
    } else {
        log.Printf("警告:ResponseWriter 不支持 Flush")
    }

    return nil
}

3. 碎碎念

发现中午的吃饭的宝藏时间,不仅可以用于锻炼还可以用来快速学习知识,真的是无比开心。Hosts 和 Clients 的实现和理解就交给后面的空余时间啦!

  • 我是一个经常笑的人,可我不是一个经常开心的人。
  • 有些爱好,如果喜欢,哪怕财力上有些吃力,也要尽可能多去体验,不要想着等有钱了再玩儿,大概率是你有钱了,但是爱好也消失了。原因很简单,没有人能逃得过熵增定律,也就是,随着时间的流逝,人生都会越来越复杂,越来越混乱,参数会越来越多,你调参的可能性越来越低,虽然是系统的总能量不变,但其中可用部分减少,哪怕是刚开始是各种不同的人生起点,但熵增到一定程度,其混乱混沌的程度看起来就都差不多,你一开始是你,但最后你和其他人也没什么区别,各种意义上的。世间最让人绝望的无非是:没心情了。
  • 👆🏻 上面这句话真的让我醍醐灌顶了,想学的跳舞要抓紧行动起来了。

4.  参考资料