package http import ( "bytes" "context" "fmt" "strconv" "strings" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/rs/zerolog/log" "github.com/sjzar/chatlog/internal/chatlog/conf" "github.com/sjzar/chatlog/internal/errors" "github.com/sjzar/chatlog/internal/model" "github.com/sjzar/chatlog/pkg/util" "github.com/sjzar/chatlog/pkg/version" ) func (s *Service) initMCPServer() { s.mcpServer = server.NewMCPServer(conf.AppName, version.Version) s.mcpServer.AddTool(ContactTool, s.handleMCPContact) s.mcpServer.AddTool(ChatRoomTool, s.handleMCPChatRoom) s.mcpServer.AddTool(RecentChatTool, s.handleMCPRecentChat) s.mcpServer.AddTool(ChatLogTool, s.handleMCPChatLog) s.mcpServer.AddTool(CurrentTimeTool, s.handleMCPCurrentTime) s.mcpServer.AddTool(DiaryTool, s.handleMCPDiary) s.mcpSSEServer = server.NewSSEServer(s.mcpServer) s.mcpStreamableServer = server.NewStreamableHTTPServer(s.mcpServer) } var ContactTool = mcp.NewTool( "query_contact", mcp.WithDescription(`查询用户的联系人信息。可以通过姓名、备注名或ID进行查询,返回匹配的联系人列表。当用户询问某人的联系方式、想了解联系人信息或需要查找特定联系人时使用此工具。参数为空时,将返回联系人列表`), mcp.WithString("keyword", mcp.Description("联系人的搜索关键词,可以是姓名、备注名或ID。")), ) var ChatRoomTool = mcp.NewTool( "query_chat_room", mcp.WithDescription(`查询用户参与的群聊信息。可以通过群名称、群ID或相关关键词进行查询,返回匹配的群聊列表。当用户询问群聊信息、想了解某个群的详情或需要查找特定群聊时使用此工具。`), mcp.WithString("keyword", mcp.Description("群聊的搜索关键词,可以是群名称、群ID或相关描述")), ) var RecentChatTool = mcp.NewTool( "query_recent_chat", mcp.WithDescription(`查询最近会话列表,包括个人聊天和群聊。当用户想了解最近的聊天记录、查看最近联系过的人或群组时使用此工具。不需要参数,直接返回最近的会话列表。`), ) var ChatLogTool = mcp.NewTool( "query_chat_log", mcp.WithDescription(`检索历史聊天记录,可根据时间、对话方、发送者和关键词等条件进行精确查询。当用户需要查找特定信息或想了解与某人/某群的历史交流时使用此工具。 【强制多步查询流程!】 当查询特定话题或特定发送者发言时,必须严格按照以下流程使用,任何偏离都会导致错误的结果: 步骤1: 初步定位相关消息 - 使用keyword参数查找特定话题 - 使用sender参数查找特定发送者的消息 - 使用较宽时间范围初步查询 步骤2: 【必须执行】针对每个关键结果点分别获取上下文 - 必须对步骤1返回的每个时间点T1, T2, T3...分别执行独立查询(时间范围接近的消息可以合并为一个查询) - 每次独立查询必须移除keyword参数 - 每次独立查询必须移除sender参数 - 每次独立查询使用"Tn前后15-30分钟"的窄范围 - 每次独立查询仅保留talker参数 步骤3: 【必须执行】综合分析所有上下文 - 必须等待所有步骤2的查询结果返回后再进行分析 - 必须综合考虑所有上下文信息后再回答用户 【严格执行规则!】 - 禁止仅凭步骤1的结果直接回答用户 - 禁止在步骤2使用过大的时间范围一次性查询所有上下文 - 禁止跳过步骤2或步骤3 - 必须对每个关键结果点分别执行独立的上下文查询 【执行示例】 正确流程示例: 1. 步骤1: chatlog(time="2023-04-01~2023-04-30", talker="工作群", keyword="项目进度") 返回结果: 4月5日、4月12日、4月20日有相关消息 2. 步骤2: - 查询1: chatlog(time="2023-04-05/09:30~2023-04-05/10:30", talker="工作群") // 注意没有keyword - 查询2: chatlog(time="2023-04-12/14:00~2023-04-12/15:00", talker="工作群") // 注意没有keyword - 查询3: chatlog(time="2023-04-20/16:00~2023-04-20/17:00", talker="工作群") // 注意没有keyword 3. 步骤3: 综合分析所有上下文后回答用户 错误流程示例: - 仅执行步骤1后直接回答 - 步骤2使用time="2023-04-01~2023-04-30"一次性查询 - 步骤2仍然保留keyword或sender参数 【自我检查】回答用户前必须自问: - 我是否对每个关键时间点都执行了独立的上下文查询? - 我是否在上下文查询中移除了keyword和sender参数? - 我是否分析了所有上下文后再回答? - 如果上述任一问题答案为"否",则必须纠正流程 返回格式:"昵称(ID) 时间\n消息内容\n昵称(ID) 时间\n消息内容" 当查询多个Talker时,返回格式为:"昵称(ID)\n[TalkerName(Talker)] 时间\n消息内容" 重要提示: 1. 当用户询问特定时间段内的聊天记录时,必须使用正确的时间格式,特别是包含小时和分钟的查询 2. 对于"今天下午4点到5点聊了啥"这类查询,正确的时间参数格式应为"2023-04-18/16:00~2023-04-18/17:00" 3. 当用户询问具体群聊中某人的聊天记录时,使用"sender"参数 4. 当用户询问包含特定关键词的聊天记录时,使用"keyword"参数`), mcp.WithString("time", mcp.Description(`指定查询的时间点或时间范围,格式必须严格遵循以下规则: 【单一时间点格式】 - 精确到日:"2023-04-18"或"20230418" - 精确到分钟(必须包含斜杠和冒号):"2023-04-18/14:30"或"20230418/14:30"(表示2023年4月18日14点30分) 【时间范围格式】(使用"~"分隔起止时间) - 日期范围:"2023-04-01~2023-04-18" - 同一天的时间段:"2023-04-18/14:30~2023-04-18/15:45" * 表示2023年4月18日14点30分到15点45分之间 【重要提示】包含小时分钟的格式必须使用斜杠和冒号:"/"和":" 正确示例:"2023-04-18/16:30"(4月18日下午4点30分) 错误示例:"2023-04-18 16:30"、"2023-04-18T16:30" 【其他支持的格式】 - 年份:"2023" - 月份:"2023-04"或"202304"`), mcp.Required()), mcp.WithString("talker", mcp.Description(`指定对话方(联系人或群组) - 可使用ID、昵称或备注名 - 多个对话方用","分隔,如:"张三,李四,工作群" - 【重要】这是多步查询中唯一应保留的参数`), mcp.Required()), mcp.WithString("sender", mcp.Description(`指定群聊中的发送者 - 仅在查询群聊记录时有效 - 多个发送者用","分隔,如:"张三,李四" - 可使用ID、昵称或备注名 【重要】查询特定发送者的消息时: 1. 第一步:使用sender参数初步定位多个相关消息时间点 2. 后续步骤:必须移除sender参数,分别查询每个时间点前后的完整对话 3. 错误示例:对所有找到的消息一次性查询大范围上下文 4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带sender)`)), mcp.WithString("keyword", mcp.Description(`搜索内容中的关键词 - 支持正则表达式匹配 - 【重要】查询特定话题时: 1. 第一步:使用keyword参数初步定位多个相关消息时间点 2. 后续步骤:必须移除keyword参数,分别查询每个时间点前后的完整对话 3. 错误示例:对所有找到的关键词消息一次性查询大范围上下文 4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带keyword)`)), ) var CurrentTimeTool = mcp.NewTool( "current_time", mcp.WithDescription(`获取当前系统时间,返回RFC3339格式的时间字符串(包含用户本地时区信息)。 使用场景: - 当用户询问"总结今日聊天记录"、"本周都聊了啥"等当前时间问题 - 当用户提及"昨天"、"上周"、"本月"等相对时间概念,需要确定基准时间点 - 需要执行依赖当前时间的计算(如"上个月5号我们有开会吗") 返回示例:2025-04-18T21:29:00+08:00 注意:此工具不需要任何输入参数,直接调用即可获取当前时间。`), ) var DiaryTool = mcp.NewTool( "query_diary", mcp.WithDescription(`查询最近N小时内(可选:24/48/72)用户参与过(至少有一条由“我”发送)的所有会话的全部消息,并按会话分组返回。适用于: 1. 让助手总结我一天聊了什么 2. 统计我今天在不同群/联系人中的发言 3. 回顾我参与过的讨论上下文 参数: - hours: 时间范围小时数,可选 24、48、72,默认 24 - talker: 可选,筛选特定会话(支持多个,用","分隔,ID/备注/昵称均可) 返回格式: 若存在会话: [会话显示名(UserName)] - N条 2025-04-18 09:30:05 我 内容 2025-04-18 09:30:15 张三 内容 ... ----------------------------- 若无结果:输出"最近Nh没有我参与的会话"。 注意:此工具返回的是完整原始消息内容(已做必要的占位符文本化),不做额外摘要。若需要摘要,请先调用本工具获取原始数据后再进行总结。`), mcp.WithString("hours", mcp.Description("时间范围小时数,可选:24、48、72;默认为24")), mcp.WithString("talker", mcp.Description("可选,会话筛选(多个用','分隔)")), ) type ContactRequest struct { Keyword string `json:"keyword"` Limit int `json:"limit"` Offset int `json:"offset"` } func (s *Service) handleMCPContact(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { var req ContactRequest if err := request.BindArguments(&req); err != nil { log.Error().Err(err).Msg("Failed to bind arguments") log.Error().Interface("request", request.GetRawArguments()).Msg("Failed to bind arguments") return errors.ErrMCPTool(err), nil } list, err := s.db.GetContacts(req.Keyword, req.Limit, req.Offset) if err != nil { log.Error().Err(err).Msg("Failed to get contacts") return errors.ErrMCPTool(err), nil } buf := &bytes.Buffer{} buf.WriteString("UserName,Alias,Remark,NickName\n") for _, contact := range list.Items { buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName)) } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: buf.String(), }, }, }, nil } type ChatRoomRequest struct { Keyword string `json:"keyword"` Limit int `json:"limit"` Offset int `json:"offset"` } func (s *Service) handleMCPChatRoom(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { var req ChatRoomRequest if err := request.BindArguments(&req); err != nil { log.Error().Err(err).Msg("Failed to bind arguments") log.Error().Interface("request", request.GetRawArguments()).Msg("Failed to bind arguments") return errors.ErrMCPTool(err), nil } list, err := s.db.GetChatRooms(req.Keyword, req.Limit, req.Offset) if err != nil { log.Error().Err(err).Msg("Failed to get chat rooms") return errors.ErrMCPTool(err), nil } buf := &bytes.Buffer{} buf.WriteString("Name,Remark,NickName,Owner,UserCount\n") for _, chatRoom := range list.Items { buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users))) } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: buf.String(), }, }, }, nil } type RecentChatRequest struct { Keyword string `json:"keyword"` Limit int `json:"limit"` Offset int `json:"offset"` } func (s *Service) handleMCPRecentChat(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { var req RecentChatRequest if err := request.BindArguments(&req); err != nil { log.Error().Err(err).Msg("Failed to bind arguments") log.Error().Interface("request", request.GetRawArguments()).Msg("Failed to bind arguments") return errors.ErrMCPTool(err), nil } data, err := s.db.GetSessions(req.Keyword, req.Limit, req.Offset) if err != nil { log.Error().Err(err).Msg("Failed to get sessions") return errors.ErrMCPTool(err), nil } buf := &bytes.Buffer{} for _, session := range data.Items { buf.WriteString(session.PlainText(120)) buf.WriteString("\n") } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: buf.String(), }, }, }, nil } type ChatLogRequest struct { Time string `form:"time"` Talker string `form:"talker"` Sender string `form:"sender"` Keyword string `form:"keyword"` Limit int `form:"limit"` Offset int `form:"offset"` Format string `form:"format"` } func (s *Service) handleMCPChatLog(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { var req ChatLogRequest if err := request.BindArguments(&req); err != nil { log.Error().Err(err).Msg("Failed to bind arguments") log.Error().Interface("request", request.GetRawArguments()).Msg("Failed to bind arguments") return errors.ErrMCPTool(err), nil } var err error start, end, ok := util.TimeRangeOf(req.Time) if !ok { log.Error().Err(err).Msg("Failed to get messages") return errors.ErrMCPTool(err), nil } if req.Limit < 0 { req.Limit = 0 } if req.Offset < 0 { req.Offset = 0 } messages, err := s.db.GetMessages(start, end, req.Talker, req.Sender, req.Keyword, req.Limit, req.Offset) if err != nil { log.Error().Err(err).Msg("Failed to get messages") return errors.ErrMCPTool(err), nil } buf := &bytes.Buffer{} if len(messages) == 0 { buf.WriteString("未找到符合查询条件的聊天记录") } for _, m := range messages { buf.WriteString(m.PlainText(strings.Contains(req.Talker, ","), util.PerfectTimeFormat(start, end), "")) buf.WriteString("\n") } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: buf.String(), }, }, }, nil } func (s *Service) handleMCPCurrentTime(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: time.Now().Local().Format(time.RFC3339), }, }, }, nil } type DiaryRequest struct { Hours string `json:"hours"` Talker string `json:"talker"` } // handleMCPDiary 实现 DiaryTool 逻辑(支持 hours 与 talker) func (s *Service) handleMCPDiary(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { var req DiaryRequest _ = request.BindArguments(&req) // 可选参数,忽略绑定错误 hours := 24 if strings.TrimSpace(req.Hours) != "" { if v, err := strconv.Atoi(req.Hours); err == nil && (v == 24 || v == 48 || v == 72) { hours = v } } end := time.Now() start := end.Add(-time.Duration(hours) * time.Hour) sessionsResp, err := s.db.GetSessions(req.Talker, 0, 0) if err != nil { return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: "获取会话失败: " + err.Error()}}}, nil } type grouped struct { Talker string TalkerName string Messages []*model.Message } groups := make([]*grouped, 0) for _, sess := range sessionsResp.Items { msgs, err := s.db.GetMessages(start, end, sess.UserName, "", "", 0, 0) if err != nil || len(msgs) == 0 { continue } hasSelf := false for _, m := range msgs { if m.IsSelf { hasSelf = true break } } if !hasSelf { continue } groups = append(groups, &grouped{Talker: sess.UserName, TalkerName: sess.NickName, Messages: msgs}) } buf := &bytes.Buffer{} if len(groups) == 0 { buf.WriteString(fmt.Sprintf("最近%dh没有我参与的会话", hours)) } else { for _, g := range groups { header := g.Talker if g.TalkerName != "" { header = fmt.Sprintf("%s(%s)", g.TalkerName, g.Talker) } buf.WriteString(fmt.Sprintf("[%s] - %d条\n", header, len(g.Messages))) for _, m := range g.Messages { sender := m.Sender if m.IsSelf { sender = "我" } if m.SenderName != "" { sender = fmt.Sprintf("%s(%s)", m.SenderName, sender) } buf.WriteString(m.Time.Format("2006-01-02 15:04:05")) buf.WriteString(" ") buf.WriteString(sender) buf.WriteString(" ") buf.WriteString(m.PlainTextContent()) buf.WriteString("\n") } buf.WriteString("-----------------------------\n") } } return &mcp.CallToolResult{Content: []mcp.Content{mcp.TextContent{Type: "text", Text: buf.String()}}}, nil }