feat(mcp): 重构 MCP 服务并修复部分问题(CSV & Voice) (#214)

* fix voice requests not responding

* update mcp protocol

* support csv format
This commit is contained in:
Sarv
2025-08-24 18:02:02 +08:00
committed by GitHub
parent 7eff9f32d1
commit cf47d1bdc5
10 changed files with 452 additions and 624 deletions
+7
View File
@@ -10,6 +10,7 @@ require (
github.com/gin-gonic/gin v1.10.1
github.com/google/uuid v1.6.0
github.com/klauspost/compress v1.18.0
github.com/mark3labs/mcp-go v0.38.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/mitchellh/mapstructure v1.5.0
github.com/pierrec/lz4/v4 v4.1.22
@@ -28,6 +29,8 @@ require (
)
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -42,11 +45,13 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
@@ -65,6 +70,8 @@ require (
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/net v0.43.0 // indirect
+15
View File
@@ -2,6 +2,10 @@ github.com/Eyevinn/mp4ff v0.49.0 h1:00eRg5/KwcLGWUbv+hlifldf74qg46G1IxoWXHrgDvw=
github.com/Eyevinn/mp4ff v0.49.0/go.mod h1:hJNUUqOBryLAzUW9wpCJyw2HaI+TCd2rUPhafoS5lgg=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
@@ -57,7 +61,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -74,6 +81,10 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I=
github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -158,6 +169,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+328
View File
@@ -0,0 +1,328 @@
package http
import (
"bytes"
"context"
"fmt"
"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/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.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
注意:此工具不需要任何输入参数,直接调用即可获取当前时间。`),
)
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
}
+63 -55
View File
@@ -2,6 +2,7 @@ package http
import (
"embed"
"encoding/csv"
"fmt"
"io/fs"
"net/http"
@@ -9,12 +10,12 @@ import (
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/pkg/util"
"github.com/sjzar/chatlog/pkg/util/dat2img"
"github.com/sjzar/chatlog/pkg/util/silk"
"github.com/gin-gonic/gin"
)
// EFS holds embedded file system data for static assets.
@@ -23,43 +24,54 @@ import (
var EFS embed.FS
func (s *Service) initRouter() {
s.initBaseRouter()
s.initMediaRouter()
s.initAPIRouter()
s.initMCPRouter()
}
router := s.GetRouter()
func (s *Service) initBaseRouter() {
staticDir, _ := fs.Sub(EFS, "static")
router.StaticFS("/static", http.FS(staticDir))
router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
router.GET("/health", func(ctx *gin.Context) {
s.router.StaticFS("/static", http.FS(staticDir))
s.router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
s.router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
s.router.GET("/health", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// Media
router.GET("/image/*key", s.GetImage)
router.GET("/video/*key", s.GetVideo)
router.GET("/file/*key", s.GetFile)
router.GET("/voice/*key", s.GetVoice)
router.GET("/data/*path", s.GetMediaData)
s.router.NoRoute(s.NoRoute)
}
// MCP Server
func (s *Service) initMediaRouter() {
s.router.GET("/image/*key", func(c *gin.Context) { s.handleMedia(c, "image") })
s.router.GET("/video/*key", func(c *gin.Context) { s.handleMedia(c, "video") })
s.router.GET("/file/*key", func(c *gin.Context) { s.handleMedia(c, "file") })
s.router.GET("/voice/*key", func(c *gin.Context) { s.handleMedia(c, "voice") })
s.router.GET("/data/*path", s.handleMediaData)
}
func (s *Service) initAPIRouter() {
api := s.router.Group("/api/v1", s.checkDBStateMiddleware())
{
router.GET("/sse", s.mcp.HandleSSE)
router.POST("/messages", s.mcp.HandleMessages)
// mcp inspector is shit
// https://github.com/modelcontextprotocol/inspector/blob/aeaf32f/server/src/index.ts#L155
router.POST("/message", s.mcp.HandleMessages)
api.GET("/chatlog", s.handleChatlog)
api.GET("/contact", s.handleContacts)
api.GET("/chatroom", s.handleChatRooms)
api.GET("/session", s.handleSessions)
}
}
// API V1 Router
api := router.Group("/api/v1", s.checkDBStateMiddleware())
{
api.GET("/chatlog", s.GetChatlog)
api.GET("/contact", s.GetContacts)
api.GET("/chatroom", s.GetChatRooms)
api.GET("/session", s.GetSessions)
}
router.NoRoute(s.NoRoute)
func (s *Service) initMCPRouter() {
s.router.Any("/mcp", func(c *gin.Context) {
s.mcpStreamableServer.ServeHTTP(c.Writer, c.Request)
})
s.router.Any("/sse", func(c *gin.Context) {
s.mcpSSEServer.ServeHTTP(c.Writer, c.Request)
})
s.router.Any("/message", func(c *gin.Context) {
s.mcpSSEServer.ServeHTTP(c.Writer, c.Request)
})
}
// NoRoute handles 404 Not Found errors. If the request URL starts with "/api"
@@ -75,7 +87,7 @@ func (s *Service) NoRoute(c *gin.Context) {
}
}
func (s *Service) GetChatlog(c *gin.Context) {
func (s *Service) handleChatlog(c *gin.Context) {
q := struct {
Time string `form:"time"`
@@ -113,6 +125,18 @@ func (s *Service) GetChatlog(c *gin.Context) {
switch strings.ToLower(q.Format) {
case "csv":
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s_%s_%s.csv", q.Talker, start.Format("2006-01-02"), end.Format("2006-01-02")))
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
csvWriter := csv.NewWriter(c.Writer)
csvWriter.Write([]string{"Time", "SenderName", "Sender", "TalkerName", "Talker", "Content"})
for _, m := range messages {
csvWriter.Write(m.CSV(c.Request.Host))
}
csvWriter.Flush()
case "json":
// json
c.JSON(http.StatusOK, messages)
@@ -131,7 +155,7 @@ func (s *Service) GetChatlog(c *gin.Context) {
}
}
func (s *Service) GetContacts(c *gin.Context) {
func (s *Service) handleContacts(c *gin.Context) {
q := struct {
Keyword string `form:"keyword"`
@@ -176,7 +200,7 @@ func (s *Service) GetContacts(c *gin.Context) {
}
}
func (s *Service) GetChatRooms(c *gin.Context) {
func (s *Service) handleChatRooms(c *gin.Context) {
q := struct {
Keyword string `form:"keyword"`
@@ -220,7 +244,7 @@ func (s *Service) GetChatRooms(c *gin.Context) {
}
}
func (s *Service) GetSessions(c *gin.Context) {
func (s *Service) handleSessions(c *gin.Context) {
q := struct {
Keyword string `form:"keyword"`
@@ -268,22 +292,7 @@ func (s *Service) GetSessions(c *gin.Context) {
}
}
func (s *Service) GetImage(c *gin.Context) {
s.GetMedia(c, "image")
}
func (s *Service) GetVideo(c *gin.Context) {
s.GetMedia(c, "video")
}
func (s *Service) GetFile(c *gin.Context) {
s.GetMedia(c, "file")
}
func (s *Service) GetVoice(c *gin.Context) {
s.GetMedia(c, "voice")
}
func (s *Service) GetMedia(c *gin.Context, _type string) {
func (s *Service) handleMedia(c *gin.Context, _type string) {
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" {
errors.Err(c, errors.InvalidArg(key))
@@ -300,11 +309,10 @@ func (s *Service) GetMedia(c *gin.Context, _type string) {
for _, k := range keys {
if len(k) != 32 {
absolutePath := filepath.Join(s.conf.GetDataDir(), k)
if _, err := os.Stat(absolutePath); os.IsNotExist(err) {
continue
if _, err := os.Stat(absolutePath); err == nil {
c.Redirect(http.StatusFound, "/data/"+k)
return
}
c.Redirect(http.StatusFound, "/data/"+k)
return
}
media, err := s.db.GetMedia(_type, k)
if err != nil {
@@ -331,7 +339,7 @@ func (s *Service) GetMedia(c *gin.Context, _type string) {
}
}
func (s *Service) GetMediaData(c *gin.Context) {
func (s *Service) handleMediaData(c *gin.Context) {
relativePath := filepath.Clean(c.Param("path"))
absolutePath := filepath.Join(s.conf.GetDataDir(), relativePath)
+10 -7
View File
@@ -5,21 +5,24 @@ import (
"net/http"
"time"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/chatlog/mcp"
"github.com/sjzar/chatlog/internal/errors"
"github.com/gin-gonic/gin"
"github.com/mark3labs/mcp-go/server"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/errors"
)
type Service struct {
conf Config
db *database.Service
mcp *mcp.Service
router *gin.Engine
server *http.Server
mcpServer *server.MCPServer
mcpSSEServer *server.SSEServer
mcpStreamableServer *server.StreamableHTTPServer
}
type Config interface {
@@ -27,7 +30,7 @@ type Config interface {
GetDataDir() string
}
func NewService(conf Config, db *database.Service, mcp *mcp.Service) *Service {
func NewService(conf Config, db *database.Service) *Service {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
@@ -47,10 +50,10 @@ func NewService(conf Config, db *database.Service, mcp *mcp.Service) *Service {
s := &Service{
conf: conf,
db: db,
mcp: mcp,
router: router,
}
s.initMCPServer()
s.initRouter()
return s
}
+2 -23
View File
@@ -11,7 +11,6 @@ import (
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/chatlog/http"
"github.com/sjzar/chatlog/internal/chatlog/mcp"
"github.com/sjzar/chatlog/internal/chatlog/wechat"
iwechat "github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/pkg/config"
@@ -28,7 +27,6 @@ type Manager struct {
// Services
db *database.Service
http *http.Service
mcp *mcp.Service
wechat *wechat.Service
// Terminal UI
@@ -51,9 +49,7 @@ func (m *Manager) Run(configPath string) error {
m.db = database.NewService(m.ctx)
m.mcp = mcp.NewService(m.db)
m.http = http.NewService(m.ctx, m.db, m.mcp)
m.http = http.NewService(m.ctx, m.db)
m.ctx.WeChatInstances = m.wechat.GetWeChatInstances()
if len(m.ctx.WeChatInstances) >= 1 {
@@ -106,13 +102,7 @@ func (m *Manager) StartService() error {
return err
}
if err := m.mcp.Start(); err != nil {
m.db.Stop() // 回滚已启动的服务
return err
}
if err := m.http.Start(); err != nil {
m.mcp.Stop() // 回滚已启动的服务
m.db.Stop()
return err
}
@@ -148,10 +138,6 @@ func (m *Manager) stopService() error {
errs = append(errs, err)
}
if err := m.mcp.Stop(); err != nil {
errs = append(errs, err)
}
if err := m.db.Stop(); err != nil {
errs = append(errs, err)
}
@@ -378,9 +364,7 @@ func (m *Manager) CommandHTTPServer(configPath string, cmdConf map[string]any) e
m.db = database.NewService(m.sc)
m.mcp = mcp.NewService(m.db)
m.http = http.NewService(m.sc, m.db, m.mcp)
m.http = http.NewService(m.sc, m.db)
if m.sc.GetAutoDecrypt() {
if err := m.wechat.StartAutoDecrypt(); err != nil {
@@ -419,10 +403,5 @@ func (m *Manager) CommandHTTPServer(configPath string, cmdConf map[string]any) e
}
}()
if err := m.mcp.Start(); err != nil {
return err
}
defer m.mcp.Stop()
return m.http.ListenAndServe()
}
-211
View File
@@ -1,211 +0,0 @@
package mcp
import (
"github.com/sjzar/chatlog/internal/mcp"
)
// MCPTools 和资源定义
var (
InitializeResponse = mcp.InitializeResponse{
ProtocolVersion: mcp.ProtocolVersion,
Capabilities: mcp.DefaultCapabilities,
ServerInfo: mcp.ServerInfo{
Name: "chatlog",
Version: "0.0.1",
},
}
ToolContact = mcp.Tool{
Name: "query_contact",
Description: "查询用户的联系人信息。可以通过姓名、备注名或ID进行查询,返回匹配的联系人列表。当用户询问某人的联系方式、想了解联系人信息或需要查找特定联系人时使用此工具。参数为空时,将返回联系人列表",
InputSchema: mcp.ToolSchema{
Type: "object",
Properties: mcp.M{
"keyword": mcp.M{
"type": "string",
"description": "联系人的搜索关键词,可以是姓名、备注名或ID。",
},
},
Required: []string{"keyword"},
},
}
ToolChatRoom = mcp.Tool{
Name: "query_chat_room",
Description: "查询用户参与的群聊信息。可以通过群名称、群ID或相关关键词进行查询,返回匹配的群聊列表。当用户询问群聊信息、想了解某个群的详情或需要查找特定群聊时使用此工具。",
InputSchema: mcp.ToolSchema{
Type: "object",
Properties: mcp.M{
"keyword": mcp.M{
"type": "string",
"description": "群聊的搜索关键词,可以是群名称、群ID或相关描述",
},
},
Required: []string{"keyword"},
},
}
ToolRecentChat = mcp.Tool{
Name: "query_recent_chat",
Description: "查询最近会话列表,包括个人聊天和群聊。当用户想了解最近的聊天记录、查看最近联系过的人或群组时使用此工具。不需要参数,直接返回最近的会话列表。",
InputSchema: mcp.ToolSchema{
Type: "object",
Properties: mcp.M{},
},
}
ToolChatLog = mcp.Tool{
Name: "chatlog",
Description: `检索历史聊天记录,可根据时间、对话方、发送者和关键词等条件进行精确查询。当用户需要查找特定信息或想了解与某人/某群的历史交流时使用此工具。
【强制多步查询流程!】
当查询特定话题或特定发送者发言时,必须严格按照以下流程使用,任何偏离都会导致错误的结果:
步骤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"参数`,
InputSchema: mcp.ToolSchema{
Type: "object",
Properties: mcp.M{
"time": mcp.M{
"type": "string",
"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"`,
},
"talker": mcp.M{
"type": "string",
"description": `指定对话方(联系人或群组)
- 可使用ID、昵称或备注名
- 多个对话方用","分隔,如:"张三,李四,工作群"
- 【重要】这是多步查询中唯一应保留的参数`,
},
"sender": mcp.M{
"type": "string",
"description": `指定群聊中的发送者
- 仅在查询群聊记录时有效
- 多个发送者用","分隔,如:"张三,李四"
- 可使用ID、昵称或备注名
【重要】查询特定发送者的消息时:
1. 第一步:使用sender参数初步定位多个相关消息时间点
2. 后续步骤:必须移除sender参数,分别查询每个时间点前后的完整对话
3. 错误示例:对所有找到的消息一次性查询大范围上下文
4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带sender)`,
},
"keyword": mcp.M{
"type": "string",
"description": `搜索内容中的关键词
- 支持正则表达式匹配
- 【重要】查询特定话题时:
1. 第一步:使用keyword参数初步定位多个相关消息时间点
2. 后续步骤:必须移除keyword参数,分别查询每个时间点前后的完整对话
3. 错误示例:对所有找到的关键词消息一次性查询大范围上下文
4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带keyword)`,
},
},
Required: []string{"time", "talker"},
},
}
ToolCurrentTime = mcp.Tool{
Name: "current_time",
Description: `获取当前系统时间,返回RFC3339格式的时间字符串(包含用户本地时区信息)。
使用场景:
- 当用户询问"总结今日聊天记录"、"本周都聊了啥"等当前时间问题
- 当用户提及"昨天"、"上周"、"本月"等相对时间概念,需要确定基准时间点
- 需要执行依赖当前时间的计算(如"上个月5号我们有开会吗")
返回示例:2025-04-18T21:29:00+08:00
注意:此工具不需要任何输入参数,直接调用即可获取当前时间。`,
InputSchema: mcp.ToolSchema{
Type: "object",
Properties: mcp.M{},
},
}
ResourceRecentChat = mcp.Resource{
Name: "最近会话",
URI: "session://recent",
Description: "获取最近的聊天会话列表",
}
ResourceTemplateContact = mcp.ResourceTemplate{
Name: "联系人信息",
URITemplate: "contact://{username}",
Description: "获取指定联系人的详细信息",
}
ResourceTemplateChatRoom = mcp.ResourceTemplate{
Name: "群聊信息",
URITemplate: "chatroom://{roomid}",
Description: "获取指定群聊的详细信息",
}
ResourceTemplateChatlog = mcp.ResourceTemplate{
Name: "聊天记录",
URITemplate: "chatlog://{talker}/{timeframe}?limit,offset",
Description: "获取与特定联系人或群聊的聊天记录",
}
)
-328
View File
@@ -1,328 +0,0 @@
package mcp
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/mcp"
"github.com/sjzar/chatlog/pkg/util"
"github.com/gin-gonic/gin"
)
type Service struct {
db *database.Service
mcp *mcp.MCP
}
func NewService(db *database.Service) *Service {
return &Service{
db: db,
}
}
// GetMCP 获取底层MCP实例
func (s *Service) GetMCP() *mcp.MCP {
return s.mcp
}
// Start 启动MCP服务
func (s *Service) Start() error {
s.mcp = mcp.NewMCP()
go s.worker()
return nil
}
// Stop 停止MCP服务
func (s *Service) Stop() error {
if s.mcp != nil {
s.mcp.Close()
}
return nil
}
// worker 处理MCP请求
func (s *Service) worker() {
for {
select {
case p, ok := <-s.mcp.ProcessChan:
if !ok {
return
}
s.processMCP(p.Session, p.Request)
}
}
}
func (s *Service) HandleSSE(c *gin.Context) {
s.mcp.HandleSSE(c)
}
func (s *Service) HandleMessages(c *gin.Context) {
s.mcp.HandleMessages(c)
}
// processMCP 处理MCP请求
func (s *Service) processMCP(session *mcp.Session, req *mcp.Request) {
var err error
switch req.Method {
case mcp.MethodInitialize:
err = s.initialize(session, req)
case mcp.MethodToolsList:
err = s.sendCustomParams(session, req, mcp.M{"tools": []mcp.Tool{
ToolContact,
ToolChatRoom,
ToolRecentChat,
ToolChatLog,
ToolCurrentTime,
}})
case mcp.MethodToolsCall:
err = s.toolsCall(session, req)
case mcp.MethodPromptsList:
err = s.sendCustomParams(session, req, mcp.M{"prompts": []mcp.Prompt{}})
case mcp.MethodResourcesList:
err = s.sendCustomParams(session, req, mcp.M{"resources": []mcp.Resource{
ResourceRecentChat,
}})
case mcp.MethodResourcesTemplateList:
err = s.sendCustomParams(session, req, mcp.M{"resourceTemplates": []mcp.ResourceTemplate{
ResourceTemplateContact,
ResourceTemplateChatRoom,
ResourceTemplateChatlog,
}})
case mcp.MethodResourcesRead:
err = s.resourcesRead(session, req)
case mcp.MethodPing:
err = s.sendCustomParams(session, req, struct{}{})
}
if err != nil {
session.WriteError(req, err)
}
}
// initialize 处理初始化请求
func (s *Service) initialize(session *mcp.Session, req *mcp.Request) error {
initReq, err := parseParams[mcp.InitializeRequest](req.Params)
if err != nil {
return fmt.Errorf("解析初始化参数失败: %v", err)
}
session.SaveClientInfo(initReq.ClientInfo)
return session.WriteResponse(req, InitializeResponse)
}
// toolsCall 处理工具调用
func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
callReq, err := parseParams[mcp.ToolsCallRequest](req.Params)
if err != nil {
return fmt.Errorf("解析工具调用参数失败: %v", err)
}
buf := &bytes.Buffer{}
switch callReq.Name {
case "query_contact":
keyword := ""
if v, ok := callReq.Arguments["keyword"]; ok {
keyword = v.(string)
}
limit := util.MustAnyToInt(callReq.Arguments["limit"])
offset := util.MustAnyToInt(callReq.Arguments["offset"])
list, err := s.db.GetContacts(keyword, limit, offset)
if err != nil {
return fmt.Errorf("无法获取联系人列表: %v", err)
}
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))
}
case "query_chat_room":
keyword := ""
if v, ok := callReq.Arguments["keyword"]; ok {
keyword = v.(string)
}
limit := util.MustAnyToInt(callReq.Arguments["limit"])
offset := util.MustAnyToInt(callReq.Arguments["offset"])
list, err := s.db.GetChatRooms(keyword, limit, offset)
if err != nil {
return fmt.Errorf("无法获取群聊列表: %v", err)
}
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)))
}
case "query_recent_chat":
keyword := ""
if v, ok := callReq.Arguments["keyword"]; ok {
keyword = v.(string)
}
limit := util.MustAnyToInt(callReq.Arguments["limit"])
offset := util.MustAnyToInt(callReq.Arguments["offset"])
data, err := s.db.GetSessions(keyword, limit, offset)
if err != nil {
return fmt.Errorf("无法获取会话列表: %v", err)
}
for _, session := range data.Items {
buf.WriteString(session.PlainText(120))
buf.WriteString("\n")
}
case "chatlog":
if callReq.Arguments == nil {
return mcp.ErrInvalidParams
}
_time := ""
if v, ok := callReq.Arguments["time"]; ok {
_time = v.(string)
}
start, end, ok := util.TimeRangeOf(_time)
if !ok {
return fmt.Errorf("无法解析时间范围")
}
talker := ""
if v, ok := callReq.Arguments["talker"]; ok {
talker = v.(string)
}
sender := ""
if v, ok := callReq.Arguments["sender"]; ok {
sender = v.(string)
}
keyword := ""
if v, ok := callReq.Arguments["keyword"]; ok {
keyword = v.(string)
}
limit := util.MustAnyToInt(callReq.Arguments["limit"])
offset := util.MustAnyToInt(callReq.Arguments["offset"])
messages, err := s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
if err != nil {
return fmt.Errorf("无法获取聊天记录: %v", err)
}
if len(messages) == 0 {
buf.WriteString("未找到符合查询条件的聊天记录")
}
for _, m := range messages {
buf.WriteString(m.PlainText(strings.Contains(talker, ","), util.PerfectTimeFormat(start, end), ""))
buf.WriteString("\n")
}
case "current_time":
buf.WriteString(time.Now().Local().Format(time.RFC3339))
default:
return fmt.Errorf("未支持的工具: %s", callReq.Name)
}
resp := mcp.ToolsCallResponse{
Content: []mcp.Content{
{Type: "text", Text: buf.String()},
},
IsError: false,
}
return session.WriteResponse(req, resp)
}
// resourcesRead 处理资源读取
func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
readReq, err := parseParams[mcp.ResourcesReadRequest](req.Params)
if err != nil {
return fmt.Errorf("解析资源读取参数失败: %v", err)
}
u, err := url.Parse(readReq.URI)
if err != nil {
return fmt.Errorf("无法解析URI: %v", err)
}
buf := &bytes.Buffer{}
switch u.Scheme {
case "contact":
list, err := s.db.GetContacts(u.Host, 0, 0)
if err != nil {
return fmt.Errorf("无法获取联系人列表: %v", err)
}
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))
}
case "chatroom":
list, err := s.db.GetChatRooms(u.Host, 0, 0)
if err != nil {
return fmt.Errorf("无法获取群聊列表: %v", err)
}
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)))
}
case "session":
data, err := s.db.GetSessions("", 0, 0)
if err != nil {
return fmt.Errorf("无法获取会话列表: %v", err)
}
for _, session := range data.Items {
buf.WriteString(session.PlainText(120))
buf.WriteString("\n")
}
case "chatlog":
start, end, ok := util.TimeRangeOf(strings.TrimPrefix(u.Path, "/"))
if !ok {
return fmt.Errorf("无法解析时间范围")
}
limit := util.MustAnyToInt(u.Query().Get("limit"))
offset := util.MustAnyToInt(u.Query().Get("offset"))
messages, err := s.db.GetMessages(start, end, u.Host, "", "", limit, offset)
if err != nil {
return fmt.Errorf("无法获取聊天记录: %v", err)
}
if len(messages) == 0 {
buf.WriteString("未找到符合查询条件的聊天记录")
}
for _, m := range messages {
buf.WriteString(m.PlainText(strings.Contains(u.Host, ","), util.PerfectTimeFormat(start, end), ""))
buf.WriteString("\n")
}
default:
return fmt.Errorf("不支持的URI: %s", readReq.URI)
}
resp := mcp.ReadingResource{
Contents: []mcp.ReadingResourceContent{
{URI: readReq.URI, Text: buf.String()},
},
}
return session.WriteResponse(req, resp)
}
// sendCustomParams 发送自定义参数
func (s *Service) sendCustomParams(session *mcp.Session, req *mcp.Request, params interface{}) error {
b, err := json.Marshal(mcp.NewResponse(req.ID, params))
if err != nil {
return fmt.Errorf("无法序列化响应: %v", err)
}
session.Write(b)
return nil
}
// parseParams 解析参数
func parseParams[T any](params interface{}) (*T, error) {
if params == nil {
return nil, errors.New("params is nil")
}
// 将 params 重新编码为 JSON
jsonData, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("无法编码 params: %v", err)
}
// 解码到目标结构体
var result T
if err := json.Unmarshal(jsonData, &result); err != nil {
return nil, fmt.Errorf("无法解码为目标结构体: %v", err)
}
return &result, nil
}
+15
View File
@@ -0,0 +1,15 @@
package errors
import "github.com/mark3labs/mcp-go/mcp"
func ErrMCPTool(err error) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: err.Error(),
},
},
IsError: true,
}
}
+12
View File
@@ -379,3 +379,15 @@ func (m *Message) PlainTextContent() string {
return fmt.Sprintf("Type: %d Content: %s", m.Type, content)
}
}
func (m *Message) CSV(host string) []string {
m.SetContent("host", host)
return []string{
m.Time.Format("2006-01-02 15:04:05"),
m.SenderName,
m.Sender,
m.TalkerName,
m.Talker,
m.PlainTextContent(),
}
}