動態

詳情 返回 返回

騰訊 tRPC-Go 教學——(8)通過泛 HTTP 能力實現和觀測 MCP 服務 - 動態 詳情

最近 MCP 大火,其實 tRPC 也可以提供泛 HTTP 接入的能力。內網其實已經對 mcp-go 進行了封裝並支持,但是相關代碼還沒有同步到開源版上。

不過實際上,在 tRPC 框架也是可以接入各種泛 HTTP 能力的。本文就以 mcp-go 和 tRPC 結合作為引子,也介紹一下在 Cursor 等 AI 生產力工具中如何開發和使用 MCP 能力吧。

系列文章

  • 騰訊 tRPC-Go 教學——(1)搭建服務
  • 騰訊 tRPC-Go 教學——(2)trpc HTTP 能力
  • 騰訊 tRPC-Go 教學——(3)微服務間調用
  • 騰訊 tRPC-Go 教學——(4)tRPC 組件生態和使用
  • 騰訊 tRPC-Go 教學——(5)filter、context 和日誌組件
  • 騰訊 tRPC-Go 教學——(6)服務發現
  • 騰訊 tRPC-Go 教學——(7)服務配置和指標上報
  • 騰訊 tRPC-Go 教學——(8)通過泛 HTTP 能力實現和觀測 MCP 服務

MCP 應用場景簡介

LLM的MCP(Model Context Protocol,模型上下文協議)是由 Anthropic 公司主導開發的一種開放協議,旨在為大型語言模型(LLM)與外部數據源、工具和服務提供標準化交互接口,解決傳統開發中因接口碎片化導致的功能擴展難題。其核心設計類似“AI領域的USB-C標準”,通過統一協議打破數據孤島,使LLM能夠安全、高效地調用外部資源。

上面官話看起來其實雲裏霧裏的,我們簡單地説:MCP 就是提供了一個大家都遵循的協議格式, 這樣你可以在你的大模型應用中調用 MCP 服務, 從而為大模型提供更多更強的能力。落地到這兩年特別火的 AI 開發工具 Copilot, Cursor 等,我們在與其對話的時候,其實我們也可以理解為它們的工作流程也是一個定製化的 MCP 流程。現在,我們可以自己給這些大模型工具提供我們自定義的 MCP 能力了。

其實賦予大模型獲取現實世界信息的能力,甚至是修改現實世界信息的能力,相信絕大多數使用 LLM 的開發者們都會想到。MCP 就是這樣的一個能力,它並沒有什麼高深的技術含量,只是由於各種機緣巧合,成為了行業內使用最為廣泛的協議罷了。


mcp-go 框架簡介

目前最流行的 Go MCP 框架就是 mark3labs/mcp-go,從去年 11 月第一個 commit 到現在不到半年就擁有了 3k+ 的 Star,足見其受歡迎程度。

在 mcp-go 的 README 頁中,給出的第一個例子非常簡單。從功能上,它包含了以下幾個部分:

聲明 MCP 服務能力和參數

    // 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"),
        ),
    )

這段代碼聲明瞭一個名為 hello_world 的 MCP 工具。其實名字不重要,更重要的是它剩餘的參數:

  • 功能描述: mcp.WithDescription("Say hello to someone") 包含的是對這個工具的完整描述, 這是一份交給大模型閲讀理解工具功能的文檔,因此開發者 務必 在此處將工具的功能完整的描述清楚,最好將工具的出參作詳細説明。只有有了足夠的資料,大模型才能夠在它的問答鏈路中,正確地識別是否應該調用該 MCP 工具
  • 入參描述: mcp.WithString("name", ... 這裏是對入參及其格式的描述。本例中,入參 name 是一個 string 類型參數。MCP 採用古老(但不一定標準)的 jsonrpc 協議進行交互,因此只要是符合 json 定義的數據類型參數,都是合法可用的。

實現 MCP 邏輯

在 mcp-go 框架下,聲明瞭 MCP 工具之後,只需要再實現一個函數用來對接 mcp-go 就行了,當 MCP 請求到來之後,自然會調用你的函數。在 mcp-go 的最簡示例中,就只簡單地返回了一個 hello message:

func helloHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    name, ok := request.Params.Arguments["name"].(string)
    if !ok {
        return nil, errors.New("name must be a string")
    }

    return mcp.NewToolResultText(fmt.Sprintf("Hello, %s!", name)), nil
}

這個例子已經是非常清晰地説明了獲取入參、返回出參的過程。當實現了這個 handler 之後,只需再加一行就可以接入到 mcp-go 框架內了:

    s.AddTool(tool, helloHandler)

使用 HTTP 接入 MCP

mcp-go 接入 HTTP 的方式

在 mcp-go 的最簡例子中,是使用 server.ServeStdio(s) 啓動 MCP 服務的。正如函數名描述的那樣,這是使用 stdio 來承接數據的輸入和輸出,這適合於個人、純本地環境。不過如果希望將自己的 MCP 服務開放使用的話,就必須得通過 HTTP 來提供 MCP 服務了。在 mcp-go 框架中,如果我們要將 mcp 通過 http 進行服務,其實只需要 server.Start() 函數就行。

我們可以看 server.Start() 方法,可以看到其實它也只是簡單實現了一下 http.Handler 接口,然後使用原生的 net/http 包啓動 HTTP 服務。

這就簡單了,其實 tRPC 也是支持這種模式的。

接入方法

在原生的 net/http 中,http.Handler 接口要求實現一個方法 ServeHTTP(http.ResponseWriter, *http.Request)。而在 tRPC-Go 中,則是按照 path 註冊 handler 的模式,每一個 handler 的類型與 http.Handler 其實差不多,只是多了一個 error 返回而已,大不了就返回 nil 嘛。

具體的實現方式,讀者可以看我的實現代碼的 serveHTTP 函數:

// serveHTTP 啓動HTTP服務
func serveHTTP(svc *trpcserver.Server, mcpSvr *server.MCPServer) {
    // 創建SSE服務器
    // SSE端點會自動變為 /mcp/sse
    // 消息端點會自動變為 /mcp/message
    sseServer := server.NewSSEServer(mcpSvr, server.WithBasePath("/mcp"))

    wrappedHTTP := &wrappedHTTP{Handler: sseServer}
    thttp.HandleFunc("/mcp/sse", wrappedHTTP.ServeHTTP)
    thttp.HandleFunc("/mcp/message", wrappedHTTP.ServeHTTP)

    thttp.RegisterNoProtocolService(svc.Service("trpc.amc.demo.mcp"))
    if err := svc.Serve(); err != nil {
        log.Errorf("TRPC server error: '%v'", err)
    }
}

MCP path

這段函數首先需要注意的部分, 在函數中的註釋已經説明了。簡單地説,通過 HTTP 暴露 MCP 服務的話,是通過 sse 和 message 兩個接口來實現的,其中前者負責給 MCP client 下發臨時憑證,後者則負責主要的數據交互。sse 和 message 兩個接口我們都可以人工指定,默認就是 base path + /sse 和 base path + /message 的模式。

HTTP Wrapping

第二個需要注意的點,就是我定義了一個 wrappedHTTP 實例,用來包裝一層 mcp-go 的 HTTP 函數。這主要是為了適配標準 net/http 的 handler 和 tRPG 的 handler 格式(加一個 error 返回)。此外,讀者也可以具體看這個類型的 實現,除了加 error 這一點之外,還攔截了一下 http 收包和回包的過程,便於我們觀察 MCP 的交互過程。

tRPC Service

第三點則是 thttp.RegisterNoProtocolService(svc.Service("trpc.amc.demo.mcp"))。在 net/http 中,啓動 HTTP 服務的時候,監聽在哪一個網卡、什麼端口,是需要在代碼中傳入的。而 tRPC 框架則將這些參數轉移到了 trpc_go.yaml 配置文件中。讀者可以看看 示例配置。

因此,這一句主要就是將 HTTP 服務與 yaml 配置文件中的具體項目綁定起來。


啓動 MCP 服務

讀者可以把我的倉庫 clone 下來,然後到 app/mcp 目錄下執行 go run . 命令,就可以在 localhost 的 8080 端口下啓動一個 MCP HTTP 服務——如果要修改啓動參數,可以在代碼中的 trpc_go.yaml 文件中修改。

Cursor 配置

因為我目前用的 IDE 是 Cursor,因此我就以 Cursor 為例子説明吧。對於 Mac 用户,打開 Cursor 之後,在菜單欄中的 Cursor 項下拉,找到 Cursor Settings:

Cursor Settings

在 Settings tab,找到 MCP:

MCP

選擇 "+ Add new global MCP server",填入以下內容:

{
  "mcpServers": {
    "demo_mcp": {
      "url": "http://localhost:8080/mcp/sse"
    }
  }
}

這個 /mcp/sse 的路徑,就對應了前文我提到的 sse 接口。

如果你的服務還沒啓動,你關掉配置頁之後可能會發現出現這樣的錯誤:

mcp error

如果你按照我前文所説的 go run . 啓動了的話,或者是啓動之後,點一下右上角的刷新按鈕,那麼就會看到樸實無華的綠燈

mcp success

測試

綠燈亮起後,我們在 Cursor 中驗證一下 MCP 工具是否生效:

Time

這裏我把思考過程和 MCP 調用過程都展開來了。可以看到,Cursor 在思考中推斷出可以用 MCP 工具來獲取當前的真實時間,然後在根據它自己的知識,推測出印度時間與北京時間的差異,最後經過 MCP 返回的數據,計算出印度時間。我們都知道,大模型的時間是滯後的,這裏給出了正確的時間,也就説明了 MCP 的有效性。


觀察 MCP 交互

前文我提到了,我使用 wrappedHTTP 攔截了輸入和輸出請求。讀者可以通過服務的標準輸出查看 Cursor 和服務的交互過程。

建立連接

首先,Cursor 啓動後,首先通過 /mcp/sse 接口與 server 建立連接並獲取實際交互的接口以及 token。就本例子來説,從日誌中我們可以觀測到,Cursor 向 /mcp/sse 發起了一個 GET 請求,然後 mcp-go 返回了以下數據:

event: endpoint
data: /mcp/message?sessionId=d4f3737e-d4ae-48b1-a1c9-7661baff8814

MCP 功能探測

如果我們點擊 Cursor 的刷新按鈕,Cursor 根據上一輪的響應,發起 /mcp/message?sessionId=d4f3737e-d4ae-48b1-a1c9-7661baff8814 請求,這次是一個 POST 請求,請求正文格式化之後為:

{
    "method": "initialize",
    "params": {
        "protocolVersion": "2024-11-05",
        "capabilities": {
            "tools": true,
            "prompts": false,
            "resources": true,
            "logging": false,
            "roots": {
                "listChanged": false
            }
        },
        "clientInfo": {
            "name": "cursor-vscode",
            "version": "1.0.0"
        }
    },
    "jsonrpc": "2.0",
    "id": 0
}

mcp-go 按照我們的配置,返回:

{
    "jsonrpc": "2.0",
    "id": 0,
    "result": {
        "protocolVersion": "2024-11-05",
        "capabilities": {
            "tools": {}
        },
        "serverInfo": {
            "name": "ip-mcp",
            "version": "1.0.0"
        }
    }
}

這是對整個 server 的初始化,不重要。接着 Cursor 再次發起一個 POST 請求:

{"method":"notifications/initialized","jsonrpc":"2.0"}

MCP 響應:

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "tools": [
            {
                "description": "\n根據行政區劃代碼獲取行政區劃名稱, 返回 JSON 格式, 包含以下字段:\n\n- province: 規範化的省級行政區名稱\n- city: 規範化的市級行政區名稱\n- county: 規範化的區縣級行政區名稱\n- province_code: 省級行政區代碼, 如廣東省為 44\n- city_code: 市級行政區代碼, 如廣州市為 01\n- county_code: 區縣級行政區代碼, 如越秀區為 04\n",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "city": {"description": "市級行政區名稱","type": "string"},
                        "county": {"description": "縣級行政區名稱","type": "string"},
                        "province": {"description": "省級行政區名稱","type": "string"}
                    },
                    "required": ["province"]
                },
                "name": "admin_division_query"
            },
            {
                "description": "\n返回當前的時間,以 JSON 格式輸出,包含以下字段:\n\n- utc: 格式為 \"YYYY-MM-DD HH:MM:SS\" 的當前 UTC 時間\n- beijing: 格式為 \"YYYY-MM-DD HH:MM:SS\" 的當前北京時間\n- timestamp_sec: 當前時間戳,單位為秒\n",
                "inputSchema": {
                    "type": "object",
                    "properties": {}
                },
                "name": "datetime_query"
            }
        ]
    }
}

這就是我們在代碼中定義的兩個工具了

MCP 邏輯交互

這次我們不用前面的 datetime 工具了,我們來問一個帶參數的。同樣,我展開了思考和 MCP 交互過程:

行政區劃

這裏大模型調用了兩次,兩次請求大同小異,我們就看第一個請求。Cursor 發起了一個請求 /mcp/message?sessionId=bac39787-91e9-4b4a-8503-090af903a662,可以看到 session ID 變化了,這是在某次 /mcp/sse 刷新的,這不重要。

這次請求依舊是 POST,請求正文為:

{
    "method": "tools/call",
    "params": {
        "name": "admin_division_query",
        "arguments": {
            "province": "雲南省",
            "city": "西雙版納"
        }
    },
    "jsonrpc": "2.0",
    "id": 4
}

參數、function call 名稱,都很清晰。MCP 的響應為

{
    "jsonrpc": "2.0",
    "id": 4,
    "result": {
        "content": [
            {
                "type": "text",
                "text": "{\"province\":\"雲南省\",\"province_code\":\"53\",\"city\":\"西雙版納傣族自治州\",\"city_code\":\"28\"}"
            }
        ]
    }
}

我是使用 text 格式返回的,然後我把字段序列化之後存在這個 text 之後。當然我們也可以看到,Cursor 的大模型拿到這個 text 之後,成功把其中的信息解析出來了。這也可見 LLM 的泛用性之強。


總結和應用

好了,這篇文章我們從MCP的基本概念聊到了如何在tRPC-Go中實現MCP服務。從最簡單的mcp-go框架示例出發,我們看到了如何在tRPC-Go框架下通過HTTP接口來提供MCP服務能力。通過幾個實際例子,我們觀察到了MCP服務與Cursor這樣的大模型工具之間建立連接、探測能力和交互的全過程。

如果你想開發自己的MCP服務,希望這篇文章能給你一些啓發,也順便展示了一下 tRPC 實現普通泛 HTTP 服務的能力。

其實我自己還實現了通過 IP 地址獲取本機地理位置的功能,然後再實現了一個獲取天氣信息的 API(基於高德 API),實驗後我們可以發現,LLM 也能夠根據所有的 MCP 工具的能力,將不同階段的參數串起來,最終實現我想要的要求,看 Cursor 的思考和調用過程其實還是蠻有趣的:

weather


參考資料

  • 使用Go開發MCP Server, 太簡單了! - ThinkInAI 社區
  • 如何使用Golang創建MCP Server - 潘子夜個人博客
  • 幾十行代碼輕鬆打造屬於自己的MCP服務器
  • 認識 MCP Go 工具

本文章採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。

原作者: amc,原文發佈於騰訊雲開發者社區,也是本人的博客。歡迎轉載,但請註明出處。

原文標題:《騰訊 tRPC-Go 教學——(8)通過泛 HTTP 能力實現和觀測 MCP 服務》

發佈日期:2025-04-18

原文鏈接:https://cloud.tencent.com/developer/article/2514815。

CC BY-NC-SA 4.0 DEED.png

user avatar free_like_bird 頭像 huaihuaidehongdou 頭像 7mandy7 頭像 yuzhoustayhungry 頭像 wnhyang 頭像 chaoxi_67109d31bc42f 頭像 _5bf4c360ce464 頭像 fulade 頭像 phytium_developers 頭像 vistart 頭像 seazhan 頭像 lpicker 頭像
點贊 16 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.