fix(mcp-server): mcp use climc struct (#25184)

This commit is contained in:
屈轩
2026-07-20 17:17:24 +08:00
committed by GitHub
parent 574cac011a
commit 21822dc778
94 changed files with 4282 additions and 5669 deletions
+55 -2
View File
@@ -304,10 +304,12 @@ func (o *openai) doChatStreamRequest(ctx context.Context, mcpAgent *models.SMCPA
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
return formatLLMHTTPError(resp.StatusCode, mcpAgent.Model, body)
}
scanner := bufio.NewScanner(resp.Body)
// 工具调用场景下单行 SSE 可能很大,提高 buffer
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
@@ -394,7 +396,7 @@ func (o *openai) doChatRequest(ctx context.Context, mcpAgent *models.SMCPAgent,
}
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
return nil, formatLLMHTTPError(resp.StatusCode, mcpAgent.Model, body)
}
var chatResp OpenAIChatResponse
@@ -511,6 +513,57 @@ func (o *openai) ConvertMCPTools(mcpTools []mcp.Tool) []models.ILLMTool {
return tools
}
// formatLLMHTTPError 把上游 JSON 错误整理成可读单行提示。
func formatLLMHTTPError(status int, model string, body []byte) error {
msg := extractLLMErrorMessage(body)
if msg == "" {
msg = strings.TrimSpace(string(body))
msg = strings.ReplaceAll(msg, "\n", " ")
msg = strings.Join(strings.Fields(msg), " ")
}
if msg == "" {
msg = http.StatusText(status)
}
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "unsupported model"):
return errors.Errorf("模型不支持:当前配置为 %q%s)", model, msg)
case status == http.StatusUnauthorized || strings.Contains(lower, "invalid api key") || strings.Contains(lower, "incorrect api key"):
return errors.Errorf("鉴权失败:请检查 Agent 的 API Key%s", msg)
case status == http.StatusTooManyRequests:
return errors.Errorf("请求过于频繁,请稍后重试(%s)", msg)
default:
return errors.Errorf("大模型接口返回 %d%s", status, msg)
}
}
func extractLLMErrorMessage(body []byte) string {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return ""
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
if errObj, ok := payload["error"].(map[string]interface{}); ok {
if m, ok := errObj["message"].(string); ok && strings.TrimSpace(m) != "" {
return strings.TrimSpace(m)
}
if m, ok := errObj["msg"].(string); ok && strings.TrimSpace(m) != "" {
return strings.TrimSpace(m)
}
}
if m, ok := payload["message"].(string); ok && strings.TrimSpace(m) != "" {
return strings.TrimSpace(m)
}
if m, ok := payload["msg"].(string); ok && strings.TrimSpace(m) != "" {
return strings.TrimSpace(m)
}
return ""
}
// Structures
type OpenAIChatMessage struct {
@@ -0,0 +1,30 @@
package llm_client
import (
"net/http"
"strings"
"testing"
)
func TestFormatLLMHTTPErrorUnsupportedModel(t *testing.T) {
body := []byte(`{
"error": {
"code": "400",
"message": "Unsupported model MiMo-V2.5"
}
}`)
err := formatLLMHTTPError(http.StatusBadRequest, "MiMo-V2.5", body)
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
if !strings.Contains(msg, "模型不支持") {
t.Fatalf("want 模型不支持, got %q", msg)
}
if !strings.Contains(msg, "MiMo-V2.5") {
t.Fatalf("want model name in message, got %q", msg)
}
if strings.Contains(msg, "\n") {
t.Fatalf("error should be single line, got %q", msg)
}
}
+224 -127
View File
@@ -199,14 +199,20 @@ func (mcp *SMCPAgent) GetApiKey() (string, error) {
func (man *SMCPAgentManager) CustomizeHandlerInfo(info *appsrv.SHandlerInfo) {
man.SSharableVirtualResourceBaseManager.CustomizeHandlerInfo(info)
// log.Infoln("query name of handler info", info.GetName(nil))
switch info.GetName(nil) {
case "get_specific":
info.SetProcessTimeout(time.Hour * 4).SetWorkerManager(mcpAgentWorkerMan)
}
}
func (man *SMCPAgentManager) SetHandlerProcessTimeout(info *appsrv.SHandlerInfo, r *http.Request) time.Duration {
// 仅 llm 侧 mcp_agents/*/chat-stream
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "chat-stream") {
return 4 * time.Hour
}
return man.SSharableVirtualResourceBaseManager.SetHandlerProcessTimeout(info, r)
}
func (man *SMCPAgentManager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, input *api.MCPAgentCreateInput) (*api.MCPAgentCreateInput, error) {
var err error
input.SharableVirtualResourceCreateInput, err = man.SSharableVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.SharableVirtualResourceCreateInput)
@@ -472,9 +478,12 @@ func (mcp *SMCPAgent) PerformChatStream(
}
w := appParams.Response
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Content-Encoding", "identity")
appParams.OverrideResponseBodyWrapper = true
if f, ok := w.(http.Flusher); ok {
f.Flush()
@@ -482,29 +491,67 @@ func (mcp *SMCPAgent) PerformChatStream(
return nil, errors.Error("Streaming unsupported!")
}
// 立刻推一条注释帧,避免 ListTools/首轮推理期间前端只看到「思考中」
if _, err := fmt.Fprintf(w, ": connected\n\n"); err == nil {
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
_, err := mcp.process(ctx, userCred, &input, func(content string) error {
if len(content) > 0 {
for line := range strings.SplitSeq(content, "\n") {
fmt.Fprintf(w, "data: %s\n", line)
}
fmt.Fprintf(w, "\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
if len(content) == 0 {
return nil
}
// 单个 SSE 事件:多行 content 用多条 data: 表示(前端按事件拼接为 \n)
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
for _, line := range strings.Split(content, "\n") {
if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "\n"); err != nil {
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
})
if err != nil {
fmt.Fprintf(w, "data: Error: %v\n\n", err)
// 单行推送,避免换行 JSON 被 SSE 截断;去掉冗长 wrap 前缀
msg := friendlyChatStreamError(err)
fmt.Fprintf(w, "data: Error: %s\n\n", msg)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
return nil, nil
}
// process 处理用户请求
// friendlyChatStreamError 面向用户的短错误文案(单行,适合 SSE)。
func friendlyChatStreamError(err error) string {
if err == nil {
return "未知错误"
}
msg := err.Error()
// 去掉 "chat stream round N: " 包装,突出真正原因
const wrap = "chat stream round "
if i := strings.Index(msg, wrap); i >= 0 {
rest := msg[i+len(wrap):]
if j := strings.Index(rest, ": "); j >= 0 {
msg = rest[j+2:]
}
}
msg = strings.ReplaceAll(msg, "\r\n", " ")
msg = strings.ReplaceAll(msg, "\n", " ")
return strings.Join(strings.Fields(msg), " ")
}
// process 处理用户请求(多轮工具调用,直到模型不再发 tool_calls 或达到上限)
func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCredential, req *api.LLMMCPAgentRequestInput, onStream func(string) error) (*api.MCPAgentResponse, error) {
// 获取 MCP Server 的工具列表
mcpServerUrl, err := mcp.GetMcpServerUrl(ctx, userCred)
if err != nil {
return nil, errors.Wrap(err, "GetMcpServerUrl")
@@ -516,128 +563,143 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden
return nil, errors.Wrap(err, "list MCP tools")
}
log.Infof("Got %d tools from MCP Server", len(mcpTools))
if onStream != nil {
_ = onStream("正在准备…\n")
}
// get llmClient
llmClient := mcp.GetLLMClientDriver()
if llmClient == nil {
return nil, errors.Error("failed to get LLM client driver")
}
tools := llmClient.ConvertMCPTools(mcpTools)
// 构建系统提示词
systemPrompt := buildSystemPrompt()
// 初始化消息历史
messages := make([]ILLMChatMessage, 0)
messages = append(messages, llmClient.NewSystemMessage(systemPrompt))
// 处理历史消息
messages = append(messages, llmClient.NewSystemMessage(buildSystemPrompt()))
if len(req.History) > 0 {
historyMessages := processHistoryMessages(
messages = append(messages, processHistoryMessages(
req.History,
llmClient,
options.Options.MCPAgentUserCharLimit,
options.Options.MCPAgentAssistantCharLimit,
)
messages = append(messages, historyMessages...)
)...)
}
messages = append(messages, llmClient.NewUserMessage(req.Message))
// 记录工具调用
var toolCallRecords []api.MCPAgentToolCallRecord
log.Infof("Phase 1: Thinking & Acting...")
// 处理流式的工具调用参数
type accumToolCall struct {
Id string
Name string
RawArguments strings.Builder
maxRounds := options.Options.MCPAgentMaxToolRounds
if maxRounds <= 0 {
maxRounds = 8
}
accToolCalls := make(map[int]*accumToolCall)
var accumulatedContent strings.Builder
var accumulatedReasoning strings.Builder
hasToolCalls := false
err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error {
if chunk.HasToolCalls() {
hasToolCalls = true
for _, tc := range chunk.GetToolCalls() {
idx := tc.GetIndex()
if _, exists := accToolCalls[idx]; !exists {
accToolCalls[idx] = &accumToolCall{
Id: tc.GetId(),
var toolCallRecords []api.MCPAgentToolCallRecord
var finalAnswer strings.Builder
nudged := false
resourceOp := looksLikeResourceOperation(req.Message)
labels := newProgressLabelCache()
for round := 1; round <= maxRounds; round++ {
log.Infof("MCP agent tool round %d/%d", round, maxRounds)
type accumToolCall struct {
Id string
Name string
RawArguments strings.Builder
}
accToolCalls := make(map[int]*accumToolCall)
var accumulatedContent strings.Builder
var accumulatedReasoning strings.Builder
hasToolCalls := false
// 首轮资源操作可能被 nudge:先不流式,避免把「计划文案」推给用户
optimisticStream := !(round == 1 && resourceOp && !nudged && len(tools) > 0)
streamedAnswer := false
// 有 tool_calls 时不推送中间文案;纯文本最终答复边生成边推送
err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error {
if chunk.HasToolCalls() {
hasToolCalls = true
for _, tc := range chunk.GetToolCalls() {
idx := tc.GetIndex()
if _, exists := accToolCalls[idx]; !exists {
accToolCalls[idx] = &accumToolCall{Id: tc.GetId()}
}
atc := accToolCalls[idx]
if id := tc.GetId(); id != "" {
atc.Id = id
}
if name := tc.GetFunction().GetName(); name != "" {
atc.Name = name
}
if args := tc.GetFunction().GetRawArguments(); args != "" {
atc.RawArguments.WriteString(args)
}
}
atc := accToolCalls[idx]
if id := tc.GetId(); id != "" {
atc.Id = id
}
if name := tc.GetFunction().GetName(); name != "" {
atc.Name = name
}
if args := tc.GetFunction().GetRawArguments(); args != "" {
atc.RawArguments.WriteString(args)
}
if r := chunk.GetReasoningContent(); len(r) > 0 {
accumulatedReasoning.WriteString(r)
}
if content := chunk.GetContent(); len(content) > 0 {
accumulatedContent.WriteString(content)
// 尚未出现 tool_calls 时按 token 增量推送,避免最终结果整段一次性返回
if onStream != nil && optimisticStream && !hasToolCalls {
streamedAnswer = true
if err := onStream(content); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return nil, errors.Wrapf(err, "chat stream round %d", round)
}
if r := chunk.GetReasoningContent(); len(r) > 0 {
accumulatedReasoning.WriteString(r)
}
content := chunk.GetContent()
if len(content) > 0 {
accumulatedContent.WriteString(content)
if onStream != nil {
if err := onStream(content); err != nil {
return err
if !hasToolCalls {
answer := accumulatedContent.String()
// 首轮对资源操作却不调工具:强制再试一轮,要求发 tool_calls
if round == 1 && resourceOp && !nudged && len(tools) > 0 {
nudged = true
log.Warningf("MCP agent round1 returned no tool_calls for resource op; nudging model")
if answer != "" {
messages = append(messages, llmClient.NewAssistantMessage(answer))
}
messages = append(messages, llmClient.NewUserMessage(
"请立刻调用合适的 climc_* 工具完成我的请求,不要只描述计划或编造查询结果。若要创建虚拟机,请从 climc_cloud_region_list 开始连续调用直到 climc_server_create。",
))
continue
}
// 未走增量推送时的兜底(例如首轮 nudge 关闭了 optimisticStream
if onStream != nil && !streamedAnswer && answer != "" {
if err := onStream(answer); err != nil {
return nil, err
}
}
finalAnswer.WriteString(answer)
return &api.MCPAgentResponse{
Success: true,
Answer: finalAnswer.String(),
ToolCalls: toolCallRecords,
}, nil
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "phase 1 chat stream error")
}
// 检查是否有工具调用
if !hasToolCalls {
// 如果阶段一没有调用工具,直接返回结果
return &api.MCPAgentResponse{
Success: true,
Answer: accumulatedContent.String(),
ToolCalls: toolCallRecords,
}, nil
}
// Convert accumulated tool calls to ILLMToolCall
var toolCalls []ILLMToolCall
// Find max index
maxIdx := -1
for idx := range accToolCalls {
if idx > maxIdx {
maxIdx = idx
toolCalls := make([]ILLMToolCall, 0)
maxIdx := -1
for idx := range accToolCalls {
if idx > maxIdx {
maxIdx = idx
}
}
}
for i := 0; i <= maxIdx; i++ {
if atc, ok := accToolCalls[i]; ok {
var args map[string]interface{}
for i := 0; i <= maxIdx; i++ {
atc, ok := accToolCalls[i]
if !ok {
continue
}
args := make(map[string]interface{})
rawArgs := atc.RawArguments.String()
if len(rawArgs) > 0 {
if err := json.Unmarshal([]byte(rawArgs), &args); err != nil {
log.Errorf("Failed to unmarshal arguments for tool %s: %v. Raw: %s", atc.Name, err, rawArgs)
args = make(map[string]interface{})
}
} else {
args = make(map[string]interface{})
}
toolCalls = append(toolCalls, &SLLMToolCall{
Id: atc.Id,
Function: SLLMFunctionCall{
@@ -646,51 +708,75 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden
},
})
}
}
log.Infof("Got %d tool calls from Phase 1", len(toolCalls))
log.Infof("Round %d got %d tool calls", round, len(toolCalls))
toolCallRecords, toolMessages, err := processToolCalls(ctx, toolCalls, accumulatedReasoning.String(), accumulatedContent.String(), mcpClient, llmClient)
if err != nil {
return nil, errors.Wrap(err, "process tool calls")
records, toolMessages, err := processToolCalls(ctx, toolCalls, accumulatedReasoning.String(), accumulatedContent.String(), mcpClient, llmClient, onStream, labels)
if err != nil {
return nil, errors.Wrap(err, "process tool calls")
}
toolCallRecords = append(toolCallRecords, records...)
messages = append(messages, toolMessages...)
}
// 工具调用相关的消息加入历史
messages = append(messages, toolMessages...)
log.Infof("Phase 2: Streaming Response...")
var finalAnswer strings.Builder
err = llmClient.ChatStream(ctx, mcp, messages, tools, func(chunk ILLMChatResponse) error {
content := chunk.GetContent()
if len(content) > 0 {
// 聚合最终答案
finalAnswer.WriteString(content)
// 实时流式输出
// 工具轮次用尽后,再给模型一轮纯文本总结(不再传 tools),避免只回“达到上限”而不解释最后一次工具错误
log.Infof("MCP agent tool rounds exhausted (%d); requesting final summary without tools", maxRounds)
messages = append(messages, llmClient.NewUserMessage(
"工具调用轮次已用尽。请根据上述工具返回结果,用中文向用户总结成功或失败原因;若创建失败请说明关键错误(如 sched_fail)与建议,不要再调用工具。",
))
var summary strings.Builder
err = llmClient.ChatStream(ctx, mcp, messages, nil, func(chunk ILLMChatResponse) error {
if content := chunk.GetContent(); len(content) > 0 {
summary.WriteString(content)
if onStream != nil {
if err := onStream(content); err != nil {
return err
}
return onStream(content)
}
}
return nil
})
if err != nil {
return nil, errors.Wrap(err, "phase 2 stream error")
log.Warningf("MCP agent final summary failed: %v", err)
msg := fmt.Sprintf("已达到最大工具调用轮次(%d),请根据已有结果继续或缩小请求范围。", maxRounds)
if onStream != nil {
_ = onStream(msg)
}
return &api.MCPAgentResponse{
Success: true,
Answer: msg,
ToolCalls: toolCallRecords,
}, nil
}
answer := strings.TrimSpace(summary.String())
if answer == "" {
answer = fmt.Sprintf("已达到最大工具调用轮次(%d),请根据已有结果继续或缩小请求范围。", maxRounds)
if onStream != nil {
_ = onStream(answer)
}
}
return &api.MCPAgentResponse{
Success: true,
Answer: finalAnswer.String(),
Answer: answer,
ToolCalls: toolCallRecords,
}, nil
}
// buildSystemPrompt 构建系统提示词
func looksLikeResourceOperation(msg string) bool {
m := strings.ToLower(msg)
keys := []string{
"创建", "查询", "列出", "列表", "启动", "停止", "重启", "删除", "销毁",
"虚拟机", "主机", "镜像", "网络", "区域", "套餐", "规格", "密码",
"create", "list", "start", "stop", "restart", "delete", "server", "vm",
}
for _, k := range keys {
if strings.Contains(m, k) {
return true
}
}
return false
}
// buildSystemPrompt 构建系统提示词(平台名来自 BaseOptions.PlatformName,支持热更新)
func buildSystemPrompt() string {
return api.MCP_AGENT_SYSTEM_PROMPT
return fmt.Sprintf(api.MCP_AGENT_SYSTEM_PROMPT, options.ResolvedPlatformName())
}
func processHistoryMessages(
@@ -744,6 +830,8 @@ func processToolCalls(
reasoningContent, content string,
mcpClient *utils.MCPClient,
llmClient ILLMClient,
onStream func(string) error,
labels *progressLabelCache,
) ([]api.MCPAgentToolCallRecord, []ILLMChatMessage, error) {
toolCallRecords := make([]api.MCPAgentToolCallRecord, 0)
messagesToAdd := make([]ILLMChatMessage, 0)
@@ -775,6 +863,15 @@ func processToolCalls(
Result: resultText,
})
labels.rememberFromTool(toolName, resultText)
// 向用户流式展示资源选择/查询摘要,而不是工具名
if onStream != nil {
if progress := summarizeToolProgress(toolName, arguments, resultText, labels); progress != "" {
_ = onStream(progress)
}
}
// 将工具执行结果加入历史
messagesToAdd = append(messagesToAdd, llmClient.NewToolMessage(tc.GetId(), toolName, resultText))
}
+624
View File
@@ -0,0 +1,624 @@
// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"encoding/json"
"fmt"
"strings"
)
// progressLabelCache 缓存 list 结果中的 id→可读名称,供后续进度文案使用。
type progressLabelCache struct {
regions map[string]string
}
func newProgressLabelCache() *progressLabelCache {
return &progressLabelCache{regions: make(map[string]string)}
}
func (c *progressLabelCache) rememberFromTool(toolName, resultText string) {
if c == nil || isToolResultError(resultText) {
return
}
name := strings.TrimPrefix(toolName, "climc_")
if strings.Contains(name, "cloud_region_list") {
c.rememberRegions(resultText)
}
}
func (c *progressLabelCache) rememberRegions(resultText string) {
if c.regions == nil {
c.regions = make(map[string]string)
}
items, _ := extractListItems(resultText)
for _, item := range items {
id := jsonString(item, "id")
name := jsonString(item, "name")
if id == "" || name == "" {
continue
}
ext := jsonString(item, "external_id")
label := name
if ext != "" && !strings.EqualFold(ext, name) {
label = fmt.Sprintf("%s%s", name, ext)
}
c.regions[id] = label
c.regions[name] = label
if ext != "" {
c.regions[ext] = label
}
}
}
func (c *progressLabelCache) regionLabel(idOrName string) string {
if idOrName == "" {
return ""
}
if c != nil && c.regions != nil {
if v := c.regions[idOrName]; v != "" {
return v
}
}
return idOrName
}
// summarizeToolProgress 将工具调用结果整理成面向用户的进度文案(逐项展示资源,而非工具名)。
func summarizeToolProgress(toolName string, args map[string]interface{}, resultText string, labels *progressLabelCache) string {
name := strings.TrimPrefix(toolName, "climc_")
if isToolResultError(resultText) {
return fmt.Sprintf("✗ %s失败:%s\n", progressLabel(name), truncateRunes(stripMCPHint(resultText), 180))
}
switch {
case name == "docs_search" || strings.HasSuffix(name, "docs_search"):
return formatResourceListProgress("文档", resultText, []string{"name", "title", "path"})
case name == "docs_get" || strings.HasSuffix(name, "docs_get"):
path := firstArg(args, "path", "PATH")
if path != "" {
return fmt.Sprintf("✓ 已阅读文档:%s\n", path)
}
return "✓ 已阅读文档\n"
case strings.Contains(name, "cloud_region_capability"):
id := firstArg(args, "id", "ID", "name")
region := labels.regionLabel(id)
types := extractStorageTypeHints(resultText)
if region != "" && types != "" {
return fmt.Sprintf("✓ 区域 %s 可用磁盘类型:%s\n", region, types)
}
if types != "" {
return fmt.Sprintf("✓ 可用磁盘类型:%s\n", types)
}
return fmt.Sprintf("✓ 已查询区域能力%s\n", paren(region))
case strings.Contains(name, "cloud_region_list"):
return formatResourceListProgress("区域", resultText, []string{"name", "external_id", "id"})
case strings.Contains(name, "cached_image_list"), strings.Contains(name, "image_list"):
return formatResourceListProgress("镜像", resultText, []string{"name", "os_type", "os_distribution", "id"})
case strings.Contains(name, "server_sku_list"):
return formatResourceListProgress("套餐", resultText, []string{"name", "instance_type_category", "cpu_core_count", "memory_size_mb", "id"})
case strings.Contains(name, "network_list"):
return formatResourceListProgress("网络", resultText, []string{"name", "guest_ip_prefix", "vpc", "id"})
case strings.Contains(name, "vpc_list"):
return formatResourceListProgress("VPC", resultText, []string{"name", "cidr_block", "id"})
case strings.Contains(name, "storage_list"):
return formatResourceListProgress("存储", resultText, []string{"name", "storage_type", "capacity", "id"})
case strings.Contains(name, "server_list"):
return formatResourceListProgress("虚拟机", resultText, []string{"name", "status", "id"})
case strings.Contains(name, "server_create"):
return formatServerCreateProgress(args, resultText, labels)
case strings.Contains(name, "server_show"):
return formatSingleResourceProgress("虚拟机详情", resultText, []string{"name", "status", "id"})
case strings.HasPrefix(name, "server_"):
id := firstArg(args, "id", "ID", "name")
action := strings.TrimPrefix(name, "server_")
return fmt.Sprintf("✓ 虚拟机%s%s\n", actionLabel(action), paren(id))
default:
return formatGenericProgress(name, args, resultText)
}
}
func isToolResultError(resultText string) bool {
s := strings.TrimSpace(resultText)
return strings.Contains(s, "调用失败") ||
strings.Contains(s, "返回错误") ||
strings.HasPrefix(s, "工具 ") && strings.Contains(s, "失败")
}
func progressLabel(toolName string) string {
switch {
case strings.Contains(toolName, "cloud_region_list"):
return "查询区域"
case strings.Contains(toolName, "cloud_region_capability"):
return "查询区域能力"
case strings.Contains(toolName, "cached_image"):
return "查询镜像"
case strings.Contains(toolName, "image_list"):
return "查询镜像"
case strings.Contains(toolName, "server_sku"):
return "查询套餐"
case strings.Contains(toolName, "network_list"):
return "查询网络"
case strings.Contains(toolName, "vpc_list"):
return "查询 VPC"
case strings.Contains(toolName, "server_create"):
return "创建虚拟机"
default:
return toolName
}
}
func actionLabel(action string) string {
switch action {
case "start":
return "已启动"
case "stop":
return "已停止"
case "restart":
return "已重启"
case "delete":
return "已删除"
case "set_password", "set-password":
return "已重置密码"
default:
return "操作完成"
}
}
func formatServerCreateProgress(args map[string]interface{}, resultText string, labels *progressLabelCache) string {
var b strings.Builder
b.WriteString("✓ 选用资源创建虚拟机:")
parts := make([]string, 0, 6)
if v := firstArg(args, "name", "NAME"); v != "" {
if isTruthy(args["generate-name"]) || isTruthy(args["generate_name"]) || isTruthy(args["GenerateName"]) {
parts = append(parts, "名称模板="+v+"(自动去重)")
} else {
parts = append(parts, "名称="+v)
}
}
if v := firstArg(args, "hypervisor"); v != "" {
parts = append(parts, "平台="+v)
}
if v := firstArg(args, "prefer-region", "prefer_region", "region"); v != "" {
parts = append(parts, "区域="+labels.regionLabel(v))
}
if v := firstArg(args, "instance-type", "instance_type", "sku"); v != "" {
parts = append(parts, "规格="+v)
}
if v := firstArg(args, "ncpu"); v != "" {
parts = append(parts, "CPU="+v)
}
if v := firstArg(args, "mem-spec", "mem_spec"); v != "" {
parts = append(parts, "内存="+v)
}
if disks := argStringSlice(args, "disk"); len(disks) > 0 {
parts = append(parts, "磁盘="+truncateRunes(disks[0], 80))
}
if nets := argStringSlice(args, "net"); len(nets) > 0 {
parts = append(parts, "网络="+strings.Join(nets, ","))
} else {
parts = append(parts, "网络=自动调度")
}
b.WriteString(strings.Join(parts, ""))
b.WriteByte('\n')
body := stripMCPHint(resultText)
if obj := parseJSONObject(body); obj != nil {
status := jsonString(obj, "final_status")
sid := jsonString(obj, "server_id")
sname := ""
if sid == "" {
if srv, ok := obj["server"].(map[string]interface{}); ok {
sid = jsonString(srv, "id")
sname = jsonString(srv, "name")
if status == "" {
status = jsonString(srv, "status")
}
}
} else if srv, ok := obj["server"].(map[string]interface{}); ok {
sname = jsonString(srv, "name")
}
if waitErr := jsonString(obj, "wait_error"); waitErr != "" {
b.WriteString(fmt.Sprintf(" 创建未完成:%s\n", truncateRunes(waitErr, 160)))
if hint := jsonString(obj, "hint"); hint != "" {
b.WriteString(fmt.Sprintf(" 提示:%s\n", truncateRunes(hint, 160)))
}
return b.String()
}
if sid != "" || status != "" || sname != "" {
b.WriteString(" 结果:")
bits := make([]string, 0, 3)
if sname != "" {
bits = append(bits, "名称="+sname)
}
if sid != "" {
bits = append(bits, "id="+sid)
}
if status != "" {
bits = append(bits, "状态="+status)
}
b.WriteString(strings.Join(bits, ""))
b.WriteByte('\n')
}
}
return b.String()
}
func isTruthy(v interface{}) bool {
switch x := v.(type) {
case bool:
return x
case string:
s := strings.ToLower(strings.TrimSpace(x))
return s == "true" || s == "1" || s == "yes" || s == "on"
case float64:
return x != 0
case int:
return x != 0
default:
return false
}
}
func formatResourceListProgress(kind, resultText string, fields []string) string {
items, total := extractListItems(resultText)
if len(items) == 0 {
if total == 0 {
return fmt.Sprintf("✓ 未找到可用%s\n", kind)
}
return fmt.Sprintf("✓ 已查询%s(共 %d 条)\n", kind, total)
}
if total <= 0 {
total = len(items)
}
labels := make([]string, 0, 5)
for i, item := range items {
if i >= 5 {
break
}
labels = append(labels, formatItemLabel(item, fields))
}
more := ""
if total > len(labels) {
more = fmt.Sprintf("等共 %d 个", total)
} else {
more = fmt.Sprintf("共 %d 个", total)
}
return fmt.Sprintf("✓ 已找到%s%s%s\n", kind, strings.Join(labels, "、"), more)
}
func formatSingleResourceProgress(kind, resultText string, fields []string) string {
obj := parseJSONObject(stripMCPHint(resultText))
if obj == nil {
return fmt.Sprintf("✓ 已获取%s\n", kind)
}
return fmt.Sprintf("✓ %s%s\n", kind, formatItemLabel(obj, fields))
}
func formatGenericProgress(toolName string, args map[string]interface{}, resultText string) string {
id := firstArg(args, "id", "ID", "name", "NAME")
items, total := extractListItems(resultText)
if len(items) > 0 {
return formatResourceListProgress(progressLabel(toolName), resultText, []string{"name", "id"})
}
if id != "" {
return fmt.Sprintf("✓ %s完成%s\n", progressLabel(toolName), paren(id))
}
_ = resultText
if total > 0 {
return fmt.Sprintf("✓ %s完成(%d 条)\n", progressLabel(toolName), total)
}
return fmt.Sprintf("✓ %s完成\n", progressLabel(toolName))
}
func formatItemLabel(item map[string]interface{}, fields []string) string {
parts := make([]string, 0, 3)
seen := map[string]bool{}
for _, f := range fields {
v := jsonString(item, f)
if v == "" || seen[v] {
continue
}
seen[v] = true
parts = append(parts, v)
if len(parts) >= 2 {
break
}
}
if len(parts) == 0 {
return "(未命名)"
}
if len(parts) == 1 {
return parts[0]
}
return fmt.Sprintf("%s(%s)", parts[0], parts[1])
}
func extractListItems(resultText string) ([]map[string]interface{}, int) {
body := stripMCPHint(resultText)
obj := parseJSONObject(body)
if obj != nil {
total := jsonInt(obj, "total")
if total <= 0 {
total = jsonInt(obj, "count")
}
for _, key := range []string{"data", "hits"} {
data, ok := obj[key].([]interface{})
if !ok {
continue
}
items := make([]map[string]interface{}, 0, len(data))
for _, d := range data {
if m, ok := d.(map[string]interface{}); ok {
items = append(items, m)
}
}
if total <= 0 {
total = len(items)
}
return items, total
}
// 单对象结果
if jsonString(obj, "id") != "" || jsonString(obj, "name") != "" {
return []map[string]interface{}{obj}, 1
}
}
if arr := parseJSONArray(body); len(arr) > 0 {
items := make([]map[string]interface{}, 0, len(arr))
for _, d := range arr {
if m, ok := d.(map[string]interface{}); ok {
items = append(items, m)
}
}
return items, len(items)
}
return nil, 0
}
func extractStorageTypeHints(resultText string) string {
obj := parseJSONObject(stripMCPHint(resultText))
if obj == nil {
return ""
}
for _, key := range []string{"storage_types2", "StorageTypes2"} {
raw, ok := obj[key]
if !ok {
continue
}
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
set := make([]string, 0, 8)
seen := map[string]bool{}
for _, v := range m {
arr, ok := v.([]interface{})
if !ok {
continue
}
for _, x := range arr {
s, ok := x.(string)
if !ok || s == "" || seen[s] {
continue
}
seen[s] = true
if i := strings.Index(s, "/"); i > 0 {
s = s[:i]
}
set = append(set, s)
if len(set) >= 6 {
return strings.Join(set, "、")
}
}
}
if len(set) > 0 {
return strings.Join(set, "、")
}
}
return ""
}
func stripMCPHint(s string) string {
s = strings.TrimSpace(s)
if i := strings.Index(s, "[MCP下一步]"); i >= 0 {
s = strings.TrimSpace(s[:i])
}
return s
}
func parseJSONObject(s string) map[string]interface{} {
s = strings.TrimSpace(s)
if s == "" || s[0] != '{' {
// 可能前后有非 JSON 文本,尝试截取第一个对象
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end <= start {
return nil
}
s = s[start : end+1]
}
var obj map[string]interface{}
if err := json.Unmarshal([]byte(s), &obj); err != nil {
return nil
}
return obj
}
func parseJSONArray(s string) []interface{} {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if s[0] != '[' {
start := strings.Index(s, "[")
end := strings.LastIndex(s, "]")
if start < 0 || end <= start {
return nil
}
s = s[start : end+1]
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return nil
}
return arr
}
func firstArg(args map[string]interface{}, keys ...string) string {
if args == nil {
return ""
}
normalize := func(k string) string {
return strings.ReplaceAll(strings.ToLower(k), "_", "-")
}
for _, key := range keys {
if v, ok := args[key]; ok {
if s := stringifyArg(v); s != "" {
return s
}
}
want := normalize(key)
for k, v := range args {
if normalize(k) == want {
if s := stringifyArg(v); s != "" {
return s
}
}
}
}
return ""
}
func argStringSlice(args map[string]interface{}, key string) []string {
if args == nil {
return nil
}
v, ok := args[key]
if !ok {
alt := strings.ReplaceAll(key, "-", "_")
v, ok = args[alt]
if !ok {
return nil
}
}
switch x := v.(type) {
case []string:
return x
case []interface{}:
out := make([]string, 0, len(x))
for _, item := range x {
if s := stringifyArg(item); s != "" {
out = append(out, s)
}
}
return out
case string:
if x != "" {
return []string{x}
}
}
return nil
}
func stringifyArg(v interface{}) string {
switch x := v.(type) {
case string:
return strings.TrimSpace(x)
case float64:
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x))
}
return fmt.Sprintf("%v", x)
case int:
return fmt.Sprintf("%d", x)
case int64:
return fmt.Sprintf("%d", x)
case bool:
return fmt.Sprintf("%v", x)
case []interface{}:
if len(x) == 0 {
return ""
}
return stringifyArg(x[0])
case []string:
if len(x) == 0 {
return ""
}
return x[0]
default:
return strings.TrimSpace(fmt.Sprint(x))
}
}
func jsonString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
v, ok := m[key]
if !ok {
return ""
}
return stringifyArg(v)
}
func jsonInt(m map[string]interface{}, key string) int {
if m == nil {
return 0
}
v, ok := m[key]
if !ok {
return 0
}
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
case json.Number:
n, _ := x.Int64()
return int(n)
default:
return 0
}
}
func paren(s string) string {
if s == "" {
return ""
}
return "" + s + ""
}
func truncateRunes(s string, max int) string {
rs := []rune(strings.TrimSpace(s))
if max <= 0 || len(rs) <= max {
return string(rs)
}
return string(rs[:max]) + "…"
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"strings"
"testing"
)
func TestSummarizeToolProgressRegionList(t *testing.T) {
result := `{
"total": 2,
"data": [
{"id": "r1", "name": "北京", "external_id": "cn-beijing"},
{"id": "r2", "name": "上海", "external_id": "cn-shanghai"}
]
}
[MCP下一步] ignore`
got := summarizeToolProgress("climc_cloud_region_list", nil, result, nil)
if !strings.Contains(got, "已找到区域") || !strings.Contains(got, "北京") {
t.Fatalf("got %q", got)
}
if strings.Contains(got, "正在调用工具") {
t.Fatalf("should not mention tool name style: %q", got)
}
}
func TestSummarizeToolProgressCreate(t *testing.T) {
args := map[string]interface{}{
"name": "ubuntu-22-04",
"hypervisor": "aliyun",
"prefer-region": "r1",
"instance-type": "ecs.t6-c1m1.large",
"disk": []interface{}{"size=30g,image=img1,backend=cloud_essd"},
}
result := `{"server_id":"s1","final_status":"running","server":{"id":"s1","status":"running"}}`
got := summarizeToolProgress("climc_server_create", args, result, nil)
if !strings.Contains(got, "选用资源创建虚拟机") || !strings.Contains(got, "ubuntu-22-04") {
t.Fatalf("got %q", got)
}
if !strings.Contains(got, "状态=running") {
t.Fatalf("expected final status in %q", got)
}
}
func TestSummarizeToolProgressCapability(t *testing.T) {
labels := newProgressLabelCache()
labels.regions["reg-1"] = "华东1(杭州)"
result := `{"storage_types2":{"aliyun":["cloud_essd/ssd","cloud_ssd/ssd"]}}`
got := summarizeToolProgress("climc_cloud_region_capability", map[string]interface{}{"id": "reg-1"}, result, labels)
if !strings.Contains(got, "cloud_essd") || !strings.Contains(got, "华东1(杭州)") {
t.Fatalf("got %q", got)
}
if strings.Contains(got, "reg-1") {
t.Fatalf("should show region name not id: %q", got)
}
}
+32 -2
View File
@@ -14,7 +14,11 @@
package options
import common_options "yunion.io/x/onecloud/pkg/cloudcommon/options"
import (
"strings"
common_options "yunion.io/x/onecloud/pkg/cloudcommon/options"
)
type LLMOptions struct {
common_options.CommonOptions
@@ -49,10 +53,11 @@ type LLMOptions struct {
// MCP Agent 配置
MCPServerURL string `help:"MCP Server URL" default:"http://default-mcp-server:30876"`
MCPAgentTimeout int `help:"MCP Agent request timeout in seconds" default:"120"`
MCPAgentTimeout int `help:"MCP Agent request timeout in seconds" default:"180"`
MCPAgentUserCharLimit int `help:"MCP Agent user char limit" default:"3200"`
MCPAgentAssistantCharLimit int `help:"MCP Agent assistant char limit" default:"6400"`
MCPAgentMaxToolRounds int `help:"Max MCP tool-call rounds per chat request" default:"16"`
// LLM model catalog (browsable curated entries). Value can be either an
// http(s) URL or a local file path; sources without an http:// or https://
@@ -73,3 +78,28 @@ type LLMOptions struct {
var (
Options LLMOptions
)
const DefaultPlatformName = "Cloudpods"
// ResolvedPlatformName 返回配置中的平台展示名,空则回退 DefaultPlatformName。
func ResolvedPlatformName() string {
name := strings.TrimSpace(Options.PlatformName)
if name == "" {
return DefaultPlatformName
}
return name
}
func OnOptionsChange(oldO, newO interface{}) bool {
oldOpts := oldO.(*LLMOptions)
newOpts := newO.(*LLMOptions)
changed := false
if common_options.OnCommonOptionsChange(&oldOpts.CommonOptions, &newOpts.CommonOptions) {
changed = true
}
if common_options.OnDBOptionsChange(&oldOpts.DBOptions, &newOpts.DBOptions) {
changed = true
}
return changed
}
+1
View File
@@ -42,6 +42,7 @@ func StartService() {
app_common.InitAuth(commonOpts, func() {
log.Infof("Auth complete!!")
})
common_options.StartOptionManager(opts, opts.ConfigSyncPeriodSeconds, api.SERVICE_TYPE, api.SERVICE_VERSION, options.OnOptionsChange)
app := app_common.InitApp(&opts.BaseOptions, false)
+73 -11
View File
@@ -20,6 +20,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
@@ -32,6 +33,8 @@ import (
"yunion.io/x/log"
"yunion.io/x/pkg/errors"
"yunion.io/x/onecloud/pkg/apis/identity"
"yunion.io/x/onecloud/pkg/llm/options"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/mcclient/auth"
)
@@ -60,21 +63,48 @@ type MCPClient struct {
messageID int64
mu sync.Mutex
initialized bool
closed atomic.Bool
userCred mcclient.TokenCredential
// requestTimeout 单次 JSON-RPC(含 tools/call)等待 SSE 回包的上限。
// 须覆盖 climc_server_create 的 forecast+等待(ServerCreateWaitSeconds),且小于整段 chat 超时。
requestTimeout time.Duration
pendingReqs map[int64]chan *rawMCPResponse
reqMu sync.Mutex
}
// NewMCPClient 创建一个新的 MCP 客户端
// NewMCPClient 创建一个新的 MCP 客户端
// timeout 为单次 RPC 等待 SSE 响应的超时;SSE 长连接本身不设整体 Timeout,避免读 body 被提前掐断。
func NewMCPClient(serverURL string, timeout time.Duration, userCred mcclient.TokenCredential) *MCPClient {
if timeout <= 0 {
timeout = time.Duration(options.Options.MCPAgentTimeout) * time.Second
}
if timeout <= 0 {
timeout = 3 * time.Minute
}
return &MCPClient{
serverURL: strings.TrimSuffix(serverURL, "/"),
client: &http.Client{
Timeout: timeout,
Timeout: 0,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext,
ResponseHeaderTimeout: 60 * time.Second,
},
},
userCred: userCred,
pendingReqs: make(map[int64]chan *rawMCPResponse),
requestTimeout: timeout,
userCred: userCred,
pendingReqs: make(map[int64]chan *rawMCPResponse),
}
}
// setAuthHeaders 将当前用户 token 写入请求头,供 mcp-server SSE/message 鉴权。
func (c *MCPClient) setAuthHeaders(req *http.Request) {
if c.userCred == nil {
return
}
if tok := strings.TrimSpace(c.userCred.GetTokenString()); tok != "" {
req.Header.Set(identity.AUTH_TOKEN_HEADER, tok)
}
}
@@ -88,6 +118,7 @@ func (c *MCPClient) connectSSE(ctx context.Context) error {
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
c.setAuthHeaders(req)
resp, err := c.client.Do(req)
if err != nil {
@@ -95,8 +126,8 @@ func (c *MCPClient) connectSSE(ctx context.Context) error {
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return errors.Errorf("SSE connection failed with status %d: %s", resp.StatusCode, string(body))
}
@@ -125,7 +156,8 @@ func (c *MCPClient) connectSSE(ctx context.Context) error {
if err != nil {
if !foundSession {
initErr = err
} else {
} else if !isExpectedSSEClose(err) && !c.closed.Load() {
// 主动 Close / 对端正常结束时不刷告警
log.Warningf("SSE connection closed: %v", err)
}
return
@@ -212,7 +244,7 @@ func (c *MCPClient) Initialize(ctx context.Context) error {
ProtocolVersion: "2024-11-05",
Capabilities: mcp.ClientCapabilities{},
ClientInfo: mcp.Implementation{
Name: "cloudpods-mcp-agent",
Name: fmt.Sprintf("%s-mcp-agent", options.ResolvedPlatformName()),
Version: "1.0.0",
},
}
@@ -318,7 +350,22 @@ func (c *MCPClient) sendRequest(ctx context.Context, req mcp.JSONRPCRequest) (*r
return &mcpResp, nil
}
// 如果响应为空,等待 SSE 推送
// 如果响应为空,等待 SSE 推送/message 常返回空 body,结果走 SSE)
wait := c.requestTimeout
if wait <= 0 {
wait = 3 * time.Minute
}
if deadline, ok := ctx.Deadline(); ok {
remain := time.Until(deadline)
if remain <= 0 {
return nil, ctx.Err()
}
if remain < wait {
wait = remain
}
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case mcpResp := <-respChan:
log.Debugf("MCP response (SSE): ID=%v", mcpResp.ID)
@@ -328,8 +375,8 @@ func (c *MCPClient) sendRequest(ctx context.Context, req mcp.JSONRPCRequest) (*r
return mcpResp, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(30 * time.Second):
return nil, errors.Error("timeout waiting for SSE response")
case <-timer.C:
return nil, errors.Errorf("timeout waiting for SSE response after %s", wait)
}
}
@@ -423,14 +470,29 @@ func FormatToolResult(toolName string, result *mcp.CallToolResult, err error) st
return GetToolResultText(result)
}
// isExpectedSSEClose 判断是否为正常关闭(主动 Close / EOF / 连接已关)
func isExpectedSSEClose(err error) bool {
if err == nil {
return true
}
if errors.Cause(err) == io.EOF || errors.Cause(err) == net.ErrClosed {
return true
}
msg := err.Error()
return strings.Contains(msg, "use of closed network connection") ||
strings.Contains(msg, "closed network connection") ||
strings.Contains(msg, "http: read on closed response body")
}
// Close 关闭客户端连接
func (c *MCPClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed.Store(true)
c.initialized = false
c.sessionURL = ""
if c.sseBody != nil {
c.sseBody.Close()
_ = c.sseBody.Close()
c.sseBody = nil
}
return nil