diff --git a/apps/proto/user/user_msg.proto b/apps/proto/user/user_msg.proto index 359c3485..1685056b 100644 --- a/apps/proto/user/user_msg.proto +++ b/apps/proto/user/user_msg.proto @@ -362,3 +362,22 @@ message UserGetUploadUrlResp{ string contenttype = 4; //⚠️ PUT 时必须带这个 Content-Type,它计入了签名,不一致会 403 int64 expiresec = 5; //预签名有效期(秒),超时要重新申请 } + +//申请 MCP 访问令牌 +// +//EMAI 助手在百炼那边调我们的 MCP 工具时,需要知道「这是哪个用户」。 +//但这条路上没有任何地方能带用户身份:客户端连百炼只带百炼的 API Key, +//百炼调 MCP 的 Authorization 头是控制台里配死的、所有用户共用。 +//唯一可行的通道是 Start 指令的 biz_params.user_defined_params(协议里明确说 +//「透传给 agent 和 mcp 服务的参数」),它会作为工具参数交到 MCP 手上。 +// +//⚠️ 因此这里签发的是**短时效、仅限 MCP 用途**的令牌,绝不能直接把登录 JWT +//丢进去——那东西等价于账号的全部权限,而这条链路要经过百炼(第三方)。 +//本令牌即使泄露,也只能读该用户的会议纪要/待办这类只读工具。 +//路由 user_getmcptoken +message UserGetMcpTokenReq{ +} +message UserGetMcpTokenResp{ + string token = 1; //@go_tags(`json:"token"`) 放进 biz_params.user_defined_params 交给百炼 + int64 expiresat = 2; //@go_tags(`json:"expiresat"`) 过期时刻(unix秒),客户端据此提前续签 +} diff --git a/apps/services/modules/mcp/auth.go b/apps/services/modules/mcp/auth.go index 70d06766..55b2c8a8 100644 --- a/apps/services/modules/mcp/auth.go +++ b/apps/services/modules/mcp/auth.go @@ -82,20 +82,40 @@ func parseUID(tokenString string, secretKey []byte) (string, error) { return claims.ID, nil } +// mcpTokenAudience 与 user 模块签发时写入的用途标记一致。 +// 必须校验:将来若有别处也用 McpTokenKey 签东西,不校验就会被一并接受。 +const mcpTokenAudience = "mcp" + // ResolveUID 决定这次工具调用该用哪个 uid。 // // argUID 是模型传上来的参数,只用于「与会话 uid 比对」,绝不作为回退值。 func (this *Mcp) ResolveUID(ctx context.Context, argUID string) (string, error) { - key := this.options.TokenKey - if key == "" { - // 没配 TokenKey 说明部署时漏了 GATEWAY_TOKEN_KEY。 - // 这时**拒绝所有用户类工具**而不是放行:配置缺失导致鉴权静默失效 - // 是最糟的一种失败方式。 - return "", fmt.Errorf("服务端未配置 TokenKey,用户数据类工具不可用") - } - uid, err := parseUID(tokenFromContext(ctx), []byte(key)) + return this.ResolveUIDWithToken(ctx, argUID, "") +} + +// ResolveUIDWithToken 在 [ResolveUID] 的基础上多认一条来源:**参数里的签名令牌**。 +// +// ## 为什么需要参数这条路 +// +// 走 Authorization 头是首选,但 EMAI 这条链路上根本送不到:客户端连百炼只带 +// 百炼的 API Key,百炼调 MCP 的 Authorization 头又是控制台里配死的、所有用户 +// 共用一个值。唯一能按用户变的通道是 Start 指令的 `biz_params.user_defined_params`, +// 它会作为**工具参数**交到这里。 +// +// ⚠️ 「参数里的令牌」和「参数里的 uid」是两回事,别混为一谈: +// 裸 uid 谁都能填,所以永远不采信;令牌带签名,伪造不了,安全性与走 header 等价。 +// +// ⚠️ 令牌用的是独立的 McpTokenKey,不是 gateway 的 TokenKey——它经过百炼 +// (第三方)之手,万一泄露也只能用于 MCP 这几个只读工具。 +// +// ⚠️ `auth_token` **刻意不声明进工具的参数 schema**:声明了大模型就会试着自己 +// 填一个值,而它根本无从知道真值,只会编一个出来——既污染参数又让日志里全是 +// 「令牌校验失败」。它由百炼从 user_defined_params 注入到调用参数里, +// 不需要模型参与,所以这里只管读、不管声明。 +func (this *Mcp) ResolveUIDWithToken(ctx context.Context, argUID, argToken string) (string, error) { + uid, err := this.resolveUIDFromSources(ctx, argToken) if err != nil { - return "", fmt.Errorf("身份校验失败: %v", err) + return "", err } argUID = strings.TrimSpace(argUID) if argUID != "" && argUID != uid { @@ -103,3 +123,74 @@ func (this *Mcp) ResolveUID(ctx context.Context, argUID string) (string, error) } return uid, nil } + +// resolveUIDFromSources 依次尝试:请求头的登录 JWT → 参数里的 MCP 令牌。 +// 两条都拿不出身份就拒,**绝不回退到参数 uid**——那等于这层没做。 +func (this *Mcp) resolveUIDFromSources(ctx context.Context, argToken string) (string, error) { + var headerErr error + if key := this.options.TokenKey; key != "" { + if raw := strings.TrimSpace(tokenFromContext(ctx)); raw != "" { + uid, err := parseUID(raw, []byte(key)) + if err == nil { + return uid, nil + } + headerErr = err + } + } + + if argToken = strings.TrimSpace(argToken); argToken != "" { + key := this.options.McpTokenKey + if key == "" { + return "", fmt.Errorf("服务端未配置 McpTokenKey,无法校验令牌") + } + uid, err := parseUIDWithAudience(argToken, []byte(key), mcpTokenAudience) + if err != nil { + return "", fmt.Errorf("令牌校验失败: %v", err) + } + return uid, nil + } + + if headerErr != nil { + return "", fmt.Errorf("身份校验失败: %v", headerErr) + } + if this.options.TokenKey == "" && this.options.McpTokenKey == "" { + // 两个密钥都没配说明部署时漏了注入。这时**拒绝所有用户类工具** + // 而不是放行:配置缺失导致鉴权静默失效是最糟的一种失败方式。 + return "", fmt.Errorf("服务端未配置 TokenKey/McpTokenKey,用户数据类工具不可用") + } + return "", fmt.Errorf("身份校验失败: 未带 Authorization,也未带 MCP 令牌") +} + +// parseUIDWithAudience 解 MCP 令牌:验签之外还要校验用途(aud)。 +// jwt/v5 默认**不校验** aud,必须显式声明 WithAudience。 +func parseUIDWithAudience(tokenString string, secretKey []byte, audience string) (string, error) { + tokenString = strings.TrimSpace(tokenString) + if tokenString == "" { + return "", fmt.Errorf("令牌为空") + } + if len(tokenString) > 7 && strings.EqualFold(tokenString[:7], "bearer ") { + tokenString = strings.TrimSpace(tokenString[7:]) + } + parsed, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, + func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return secretKey, nil + }, + jwt.WithAudience(audience), + // 过期是这套令牌的核心保障,必须要求 exp 存在——没有 exp 的令牌等同长期有效 + jwt.WithExpirationRequired(), + ) + if err != nil { + return "", err + } + claims, ok := parsed.Claims.(*jwt.RegisteredClaims) + if !ok || !parsed.Valid { + return "", fmt.Errorf("invalid token") + } + if claims.ID == "" { + return "", fmt.Errorf("token 里没有 uid") + } + return claims.ID, nil +} diff --git a/apps/services/modules/mcp/auth_mcptoken_test.go b/apps/services/modules/mcp/auth_mcptoken_test.go new file mode 100644 index 00000000..c36a804b --- /dev/null +++ b/apps/services/modules/mcp/auth_mcptoken_test.go @@ -0,0 +1,138 @@ +package mcp + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// 签发在 user 模块、校验在 mcp 模块,两边隔着一个进程, +// 写错了不会编译报错,只会在真机上静默拒绝(或更糟:静默放行)。 +// 这组用例把两边的口径钉在一起。 + +const testMcpKey = "test-mcp-key" + +// signLikeUserModule 复刻 user/api_getmcptoken.go 的签发方式。 +// ⚠️ 改那边的 claims 结构时,这里要一起改,否则这组用例就失去意义了。 +func signLikeUserModule(t *testing.T, uid, audience string, ttl time.Duration, key string) string { + t.Helper() + claims := &jwt.RegisteredClaims{ + ID: uid, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + } + if audience != "" { + claims.Audience = jwt.ClaimStrings{audience} + } + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(key)) + if err != nil { + t.Fatalf("签发失败: %v", err) + } + return s +} + +func TestParseUIDWithAudience(t *testing.T) { + const uid = "2095078768153460736" + + t.Run("正常令牌能解出uid", func(t *testing.T) { + tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) + got, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience) + if err != nil { + t.Fatalf("不该失败: %v", err) + } + if got != uid { + t.Fatalf("uid 不对: got=%s want=%s", got, uid) + } + }) + + t.Run("换了密钥必须拒", func(t *testing.T) { + tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, "another-key") + if _, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience); err == nil { + t.Fatal("别人签的令牌居然通过了") + } + }) + + t.Run("用途不符必须拒", func(t *testing.T) { + // 这条守的是「将来若有别处也用 McpTokenKey 签东西,别被 MCP 一并接受」 + tok := signLikeUserModule(t, uid, "someother", time.Hour, testMcpKey) + if _, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience); err == nil { + t.Fatal("用途不符的令牌居然通过了") + } + }) + + t.Run("没有用途标记必须拒", func(t *testing.T) { + tok := signLikeUserModule(t, uid, "", time.Hour, testMcpKey) + if _, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience); err == nil { + t.Fatal("没有 aud 的令牌居然通过了") + } + }) + + t.Run("过期必须拒", func(t *testing.T) { + tok := signLikeUserModule(t, uid, mcpTokenAudience, -time.Minute, testMcpKey) + if _, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience); err == nil { + t.Fatal("过期令牌居然通过了") + } + }) + + t.Run("没有过期时间必须拒", func(t *testing.T) { + // 没有 exp 的令牌等同长期有效,这套「短时效」的前提就没了 + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.RegisteredClaims{ + ID: uid, + Audience: jwt.ClaimStrings{mcpTokenAudience}, + }).SignedString([]byte(testMcpKey)) + if err != nil { + t.Fatalf("签发失败: %v", err) + } + if _, err := parseUIDWithAudience(s, []byte(testMcpKey), mcpTokenAudience); err == nil { + t.Fatal("没有 exp 的令牌居然通过了") + } + }) + + t.Run("Bearer前缀要能兼容", func(t *testing.T) { + tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) + if _, err := parseUIDWithAudience("Bearer "+tok, []byte(testMcpKey), mcpTokenAudience); err != nil { + t.Fatalf("带 Bearer 前缀不该失败: %v", err) + } + }) +} + +func TestResolveUIDWithToken(t *testing.T) { + const uid = "2095078768153460736" + m := &Mcp{} + m.options = &Options{McpTokenKey: testMcpKey} + + t.Run("参数令牌可作为身份来源", func(t *testing.T) { + tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) + got, err := m.ResolveUIDWithToken(t.Context(), "", tok) + if err != nil { + t.Fatalf("不该失败: %v", err) + } + if got != uid { + t.Fatalf("uid 不对: %s", got) + } + }) + + t.Run("参数uid与令牌不符必须拒", func(t *testing.T) { + tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) + if _, err := m.ResolveUIDWithToken(t.Context(), "9999", tok); err == nil { + t.Fatal("冒充别人居然通过了") + } + }) + + t.Run("只给裸uid不给令牌必须拒", func(t *testing.T) { + // 这条是整层鉴权的底线:裸 uid 谁都能填,绝不能作为身份来源 + if _, err := m.ResolveUIDWithToken(t.Context(), uid, ""); err == nil { + t.Fatal("只凭一个 uid 参数就放行了") + } + }) + + t.Run("没配密钥时带令牌也要拒", func(t *testing.T) { + empty := &Mcp{} + empty.options = &Options{} + tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) + if _, err := empty.ResolveUIDWithToken(t.Context(), "", tok); err == nil { + t.Fatal("密钥没配却放行了") + } + }) +} diff --git a/apps/services/modules/mcp/module.go b/apps/services/modules/mcp/module.go index b7042a29..c01bb44f 100644 --- a/apps/services/modules/mcp/module.go +++ b/apps/services/modules/mcp/module.go @@ -95,6 +95,9 @@ func (this *Mcp) OnInstallComp() { this.RegisterComp(new(tool_get_memory_stats)) this.RegisterComp(new(tool_get_memory_report)) + // 会议纪要(只读):回答「我上次开会定了啥」 + this.RegisterComp(new(tool_search_meeting_notes)) + this.RegisterComp(new(tool_allhelp_task)) this.RegisterComp(new(tool_get_user_tasks)) this.RegisterComp(new(tool_cancel_user_task)) diff --git a/apps/services/modules/mcp/options.go b/apps/services/modules/mcp/options.go index 06a9c12e..c7a1eb32 100644 --- a/apps/services/modules/mcp/options.go +++ b/apps/services/modules/mcp/options.go @@ -19,6 +19,10 @@ type ( // 留空时所有用户数据类工具一律拒绝服务 —— 配置漏了导致鉴权静默失效 // 比工具不可用严重得多。 TokenKey string + // McpTokenKey 校验 user_getmcptoken 签发的 MCP 令牌用, + // **必须与 user 模块的同名配置一致**。它与 TokenKey 是两把不同的钥匙: + // MCP 令牌要经过百炼(第三方)之手,不能用能开全站的那把去签。 + McpTokenKey string } ) diff --git a/apps/services/modules/mcp/tool_allhelp_task.go b/apps/services/modules/mcp/tool_allhelp_task.go index 0834b7f9..766fa7bb 100644 --- a/apps/services/modules/mcp/tool_allhelp_task.go +++ b/apps/services/modules/mcp/tool_allhelp_task.go @@ -72,7 +72,7 @@ func (this *tool_allhelp_task) Handl(ctx context.Context, request mcp.CallToolRe // Authorization 里的 JWT 解出来(见 auth.go)。此前这里直接信任模型填的 uid, // 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。 argUID := request.GetString("uid", "") - uid, err := this.module.ResolveUID(ctx, argUID) + uid, err := this.module.ResolveUIDWithToken(ctx, argUID, request.GetString("auth_token", "")) if err != nil { return mcp.NewToolResultError(err.Error()), nil } diff --git a/apps/services/modules/mcp/tool_cancel_user_task.go b/apps/services/modules/mcp/tool_cancel_user_task.go index a5172f1b..7ed8aab8 100644 --- a/apps/services/modules/mcp/tool_cancel_user_task.go +++ b/apps/services/modules/mcp/tool_cancel_user_task.go @@ -57,7 +57,7 @@ func (this *tool_cancel_user_task) Handl(ctx context.Context, request mcp.CallTo // Authorization 里的 JWT 解出来(见 auth.go)。此前这里直接信任模型填的 uid, // 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。 argUID := request.GetString("uid", "") - uid, err := this.module.ResolveUID(ctx, argUID) + uid, err := this.module.ResolveUIDWithToken(ctx, argUID, request.GetString("auth_token", "")) if err != nil { return mcp.NewToolResultError(err.Error()), nil } diff --git a/apps/services/modules/mcp/tool_get_user_tasks.go b/apps/services/modules/mcp/tool_get_user_tasks.go index dc6ea898..a25af0ce 100644 --- a/apps/services/modules/mcp/tool_get_user_tasks.go +++ b/apps/services/modules/mcp/tool_get_user_tasks.go @@ -59,7 +59,7 @@ func (this *tool_get_user_tasks) Handl(ctx context.Context, request mcp.CallTool // Authorization 里的 JWT 解出来(见 auth.go)。此前这里直接信任模型填的 uid, // 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。 argUID := request.GetString("uid", "") - uid, err := this.module.ResolveUID(ctx, argUID) + uid, err := this.module.ResolveUIDWithToken(ctx, argUID, request.GetString("auth_token", "")) if err != nil { return mcp.NewToolResultError(err.Error()), nil } diff --git a/apps/services/modules/mcp/tool_meeting.go b/apps/services/modules/mcp/tool_meeting.go new file mode 100644 index 00000000..a5b7717c --- /dev/null +++ b/apps/services/modules/mcp/tool_meeting.go @@ -0,0 +1,401 @@ +package mcp + +import ( + "context" + "sort" + "strings" + "time" + + "yunyan/comm" + "yunyan/lego/core" + "yunyan/lego/core/cbase" + "yunyan/lego/sys/mysql" + "yunyan/pb" + "yunyan/utils" + + "gorm.io/gorm" + + "github.com/mark3labs/mcp-go/mcp" +) + +/* +会议纪要的 MCP 工具:让 EMAI 能回答「我上次开会定了啥」「上个月关于XX的会议说了什么」。 + +## 为什么做成 MCP 工具,而不是握手时把纪要塞进会话 + +塞进会话只能在 Start 那一刻做,那时用户还没开口,不知道他要问什么——结果是 +闲聊、问天气也要白查一遍,还平白占掉上下文。做成工具则由模型自己判断该不该调: +问天气就不碰它,问会议才查一次。 + +## 为什么不能靠「知识库按 user_id 标签过滤」来隔离 + +实测过(2026-09-07):multimodal-dialog 的 Start 指令里,`biz_params` 只认 +`user_defined_params` / `user_prompt_params` / `user_query_params` 三个子字段, +塞 `rag_options` 进去服务端**静默丢弃**——对照实验里塞一个纯属虚构的字段 +`__no_such_field_zzz__`,服务端同样回 Started,两者待遇完全一样。 +也就是说对话链路上**没有任何入口**能告诉知识库「只准检索这个用户的文件」。 +给文件打 uid 标签是能做到的,但没人会去看那个标签。 + +所以过滤必须落在我们自己这一侧:本工具的 uid 来自 Authorization 里的 JWT +(见 auth.go 的 ResolveUID),模型传什么参数都不作数,跨用户读不到。 + +## 只读 + +不提供修改/删除。写路径在 home 的 echomeet 模块里,在 MCP 复制一份必然漂移。 + +## 检索方案:现在是「自家 MySQL + ngram 全文索引」,不是向量检索 + +数据一直在 `echomeet_record.summary` 里,**没有上传到百炼知识库**。每次调用现查现喂。 +这么选是因为:隔离是硬的(uid 从我们签的令牌解出,不依赖百炼任何配置)、 +会议内容不出库、改检索逻辑不用重灌数据、也不用维护入库那套异步流程和 FileId 映射。 + +三级检索,逐级回退(见 Handl):全文索引(相关度排序) → LIKE(兜单字和索引缺失) → 最近几条。 + +⚠️ **已知上限:这是词面匹配,不是语义匹配。** +问「关于降本增效的会议」,纪要里写的是「成本优化」,ngram 依然匹配不到。 +全文索引解决的是「多关键词、词序不同、按相关度排序」,解决不了同义改写。 + +**什么时候该换成向量/知识库方案**(2026-09-07 与用户明确约定:先做这版,量大了再说): +出现下面任一条就该重新评估,别等它自己变好—— + 1. 单用户会议记录数上千,`MATCH` 扫描明显变慢(现在全库才几十条); + 2. 真机上反复出现「问得对但查不到」,且原因是同义词而不是关键词提取; + 3. 需要跨会议做归纳(「今年关于 A 项目一共讨论过几次」), + 这类问题靠取 3 条纪要喂给模型答不了。 +换的时候优先考虑:纪要入百炼知识库打 uid 标签,**检索仍由本工具发起** +(调百炼 Retrieve 接口 + SearchFilters 按 uid 过滤),这样隔离强度与现在一致。 +*/ + +// 单条纪要塞给模型的最大字符数。整篇纪要可达数千字, +// 一次命中三五条就足以把上下文顶满,模型反而抓不住重点。 +const meetingSummaryMaxRunes = 1200 + +// 关键词最多几个。模型偶尔会把整句话拆成十几个词, +// 每个都 LIKE 一遍在 MySQL 上是全表扫描。 +const meetingMaxKeywords = 5 + +type tool_search_meeting_notes struct { + cbase.ModuleCompBase + module *Mcp +} + +func (this *tool_search_meeting_notes) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { + this.ModuleCompBase.Init(service, module, comp, opt) + this.module = module.(*Mcp) + return +} + +func (this *tool_search_meeting_notes) Start() (err error) { + err = this.ModuleCompBase.Start() + this.module.AddTool(ToolGroup_GLOBAL, this.Tool(), this.Handl) + return +} + +func (this *tool_search_meeting_notes) Tool() mcp.Tool { + return mcp.NewTool("search_meeting_notes", + mcp.WithDescription("查询用户自己的会议纪要。用户问到「上次开会」「会议上定了什么」"+ + "「关于某个项目的会议」这类问题时调用。不要用它回答与会议无关的问题。"), + mcp.WithString("keywords", + mcp.Description("检索关键词,多个用空格分隔;留空则按时间返回最近的会议"), + ), + mcp.WithString("start_date", mcp.Description("起始日期 YYYY-MM-DD,可省略")), + mcp.WithString("end_date", mcp.Description("结束日期 YYYY-MM-DD,可省略")), + mcp.WithNumber("limit", + mcp.Description("返回几条会议(默认3,最大5)。纪要很长,条数多了模型抓不住重点"), + mcp.DefaultNumber(3), + ), + // uid 只为兼容后台已配好的工具定义,传上来只会拿去和会话 uid 比对 + mcp.WithString("uid", mcp.Description("用户ID(可省略,服务端以登录身份为准)")), + ) +} + +func (this *tool_search_meeting_notes) Handl(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + uid, err := this.module.ResolveUIDWithToken(ctx, + request.GetString("uid", ""), request.GetString("auth_token", "")) + if err != nil { + // ⚠️ 校验失败时把**收到的参数名**记下来(只记名字,不记值——值里就是令牌本身)。 + // 「百炼把 user_defined_params 交过来时到底长什么形状」文档没写死, + // 只说「透传给 agent 和 mcp 服务」。没有这行日志,一旦它不是按 + // auth_token 这个键传过来,现象就只是「身份校验失败」,无从下手。 + // ⚠️ 用 safeLogErrorf(全局 log)而不是 this.module.Warnf: + // 实测模块自带的 Warnf 在 mcp 这个服务里既不进 stdout 也不进 log/mcp.log, + // 排查时等于没有。core.go 里那条工具异常日志用的就是这个。 + safeLogErrorf("search_meeting_notes 身份校验失败: %v;收到的参数名=%v", + err, argNames(request.GetRawArguments())) + return mcp.NewToolResultError(err.Error()), nil + } + + limit := int(request.GetInt("limit", 3)) + if limit <= 0 || limit > 5 { + limit = 3 + } + + startTs, _ := parseMeetingDay(request.GetString("start_date", ""), false) + endTs, _ := parseMeetingDay(request.GetString("end_date", ""), true) + keywords := splitMeetingKeywords(request.GetString("keywords", "")) + + tx := mysql.Table(comm.TableEchomeetRecord).Where("uid = ?", uid) + + // 只找总结真的做完、且有实质内容的。 + // ⚠️ 不能只判 `summary <> ''`:真机库里存在 summary 只有 2 个字符的记录 + // (极短录音总结出来的残次品)。它照样能通过非空判断,占掉 limit 的名额, + // 把真正有内容的会议挤出去。 + tx = tx.Where("CHAR_LENGTH(summary) >= ?", 20) + + // 时间范围:记录上没有「会议实际发生日期」这个字段,只能用 creationtime + // (= 录音上传时间)。补录旧录音时会偏,这是已知偏差,不是这里能修的。 + if startTs > 0 { + tx = tx.Where("creationtime >= ?", startTs) + } + if endTs > 0 { + tx = tx.Where("creationtime <= ?", endTs) + } + + records := make([]*pb.DBEchoMeetRecord, 0, limit) + matched := "" // 实际用了哪种检索方式,只为日志和返回值里说明 + + if len(keywords) == 0 { + if e := tx.Order("creationtime DESC").Limit(limit).Find(&records).Error; e != nil { + safeLogErrorf("search_meeting_notes 查询失败: %v", e) + return mcp.NewToolResultError("查询失败"), nil + } + matched = "recent" + } else { + // 先走全文索引(相关度排序),失败或没命中再退回 LIKE。 + // ⚠️ 这两级不能省: + // - 索引可能还没建出来(老部署、权限不足),MATCH 会直接报错; + // - ngram_token_size=2 时**单字关键词进不了索引**,只有 LIKE 找得到。 + var ftErr error + records, ftErr = this.searchFulltext(uid, startTs, endTs, keywords, limit) + if ftErr != nil { + safeLogErrorf("search_meeting_notes 全文检索不可用,退回 LIKE: %v", ftErr) + } else if len(records) > 0 { + matched = "fulltext" + } + if len(records) == 0 { + likeTx := this.recentQuery(uid, startTs, endTs) + for _, kw := range keywords { + // like 参数化拼装,别手拼 SQL + like := "%" + kw + "%" + likeTx = likeTx.Where("(title LIKE ? OR summary LIKE ? OR overview LIKE ?)", + like, like, like) + } + records = records[:0] + if e := likeTx.Order("creationtime DESC").Limit(limit).Find(&records).Error; e != nil { + safeLogErrorf("search_meeting_notes LIKE 查询失败: %v", e) + } else if len(records) > 0 { + matched = "like" + } + } + } + + // ⚠️ 关键词一条都没命中时,回退成「按时间取最近几条」。 + // + // 用户问得越自然,模型越容易把整句话塞进 keywords ——「最近的会议纪要」 + // 里的「最近」在纪要正文中根本不会出现,LIKE 必然全落空, + // 用户看到的就是助手说「查不到您的会议纪要」,而库里明明有。 + // 真机上就是这么发生的(一次调用返回 total=0)。 + // + // 回退结果里带 fallback 标记,让模型知道这不是精确匹配, + // 别把「最近一次会议」当成「你问的那次会议」来回答。 + fellBack := false + if len(records) == 0 && len(keywords) > 0 { + records = records[:0] + if e := this.recentQuery(uid, startTs, endTs).Order("creationtime DESC"). + Limit(limit).Find(&records).Error; e != nil { + safeLogErrorf("search_meeting_notes 回退查询失败: %v", e) + } else if len(records) > 0 { + fellBack = true + matched = "recent" + } + } + + out := map[string]interface{}{ + "result": "success", + "total": len(records), + "meetings": briefMeetings(records), + } + if matched != "" { + out["matched_by"] = matched + } + if fellBack { + out["fallback"] = "关键词未命中,以下是该用户最近的会议,供参考" + } + if len(records) == 0 { + out["hint"] = "该用户目前没有已完成总结的会议记录" + } + return mcp.NewToolResultText(utils.ToString(out)), nil +} + +// recentQuery 不带关键词的「最近会议」查询,与主查询共用同一套过滤条件 +// (只看本人、只看总结完成的、时间范围)。 +func (this *tool_search_meeting_notes) recentQuery(uid string, start, end int64) *gorm.DB { + tx := mysql.Table(comm.TableEchomeetRecord). + Where("uid = ?", uid). + Where("CHAR_LENGTH(summary) >= ?", 20) + if start > 0 { + tx = tx.Where("creationtime >= ?", start) + } + if end > 0 { + tx = tx.Where("creationtime <= ?", end) + } + return tx +} + +// argNames 只取参数名,不碰值。排查「令牌到底以什么键名传过来」用。 +func argNames(raw any) []string { + m, ok := raw.(map[string]any) + if !ok { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// briefMeetings 只挑模型答题用得上的字段。 +// 绝不要把整行记录丢给模型——里面有音频地址、任务 id、服务商 id 这些它用不上 +// 也不该看到的东西,还会把上下文撑爆。 +func briefMeetings(records []*pb.DBEchoMeetRecord) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(records)) + for _, r := range records { + if r == nil { + continue + } + item := map[string]interface{}{ + "title": r.Title, + "date": comm.FormatMemoryDate(time.Unix(r.Creationtime, 0)), + "summary": truncateRunes(r.Summary, meetingSummaryMaxRunes), + } + if s := strings.TrimSpace(r.Overview); s != "" { + item["overview"] = truncateRunes(s, meetingSummaryMaxRunes) + } + // ⚠️ personnel 在真机上多数是 `[Speaker_0] [Speaker_1]` 这种占位标签, + // 不是真名字。原样喂给模型只是噪声,还占上下文——只在用户真的改过 + // 说话人名字时才带上。 + if v := meaningfulPersonnel(r.Personnel); v != "" { + item["personnel"] = v + } + // 用户创建记录时写的备注,是会议背景的一部分,对答题有用 + if v := strings.TrimSpace(r.Remark); v != "" { + item["remark"] = truncateRunes(v, 300) + } + out = append(out, item) + } + return out +} + +// meaningfulPersonnel 过滤掉「全是 [Speaker_N] 占位标签」的情况。 +// 只要有一段不是占位标签,就认为用户命名过,整串原样返回。 +func meaningfulPersonnel(raw string) string { + v := strings.TrimSpace(raw) + if v == "" { + return "" + } + for _, f := range strings.Fields(v) { + f = strings.Trim(f, "[]") + if f == "" { + continue + } + if !strings.HasPrefix(f, "Speaker_") { + return v + } + } + return "" +} + +// truncateRunes 按**字符**而不是字节截断。按字节切会把一个汉字劈成两半, +// 拼出非法 UTF-8,下游 json 编码要么报错要么变成乱码。 +func truncateRunes(s string, max int) string { + rs := []rune(s) + if len(rs) <= max { + return s + } + return string(rs[:max]) + "…(内容较长已截断)" +} + +// splitMeetingKeywords 把模型给的关键词串拆成词,去空、去重、限量。 +func splitMeetingKeywords(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' || r == ',' || r == ',' || r == '、' + }) + seen := make(map[string]bool, len(fields)) + out := make([]string, 0, meetingMaxKeywords) + for _, f := range fields { + f = strings.TrimSpace(f) + if f == "" || seen[f] { + continue + } + seen[f] = true + out = append(out, f) + if len(out) >= meetingMaxKeywords { + break + } + } + return out +} + +// parseMeetingDay 把 YYYY-MM-DD 转成 unix 秒。endOfDay=true 时取当天 23:59:59, +// 否则取 00:00:00 —— 只写 end_date 而按 00:00:00 比较,会把那一天整天都排除掉。 +// +// ⚠️ 用的是容器本地时区(Asia/Shanghai),与 memory 模块口径一致。 +// 海外用户的「今天」会偏,这是全项目的既有取舍,不在这里单独处理。 +func parseMeetingDay(s string, endOfDay bool) (int64, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + d, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return 0, false + } + if endOfDay { + d = d.Add(24*time.Hour - time.Second) + } + return d.Unix(), true +} + +// fulltextColumns 必须与 echomeet 建索引时的列**逐字一致**, +// 否则 MySQL 报 "Can't find FULLTEXT index matching the column list"。 +// 建索引处:modules/echomeet/model.go 的 recordFulltextColumns。 +const fulltextColumns = "title,summary,overview,remark" + +// searchFulltext 走 ngram 全文索引,按相关度排序。 +// +// 用 BOOLEAN MODE + 每个关键词加引号: +// - 加引号是为了**转义**——boolean 模式里 + - > < ( ) ~ * @ 都是操作符, +// 模型传来的自然语言里随时可能带上,不引起来轻则语义跑偏、重则语法报错; +// - 不加 `+` 前缀(即 OR 语义)是刻意的:用户问「关于降本增效的会议」, +// 模型常把整句拆成好几个词,要求全部命中(AND)会把召回压到 0, +// 而全文索引本来就会按命中数和词频打分排序,OR + 排序的效果更好。 +func (this *tool_search_meeting_notes) searchFulltext( + uid string, startTs, endTs int64, keywords []string, limit int, +) ([]*pb.DBEchoMeetRecord, error) { + terms := make([]string, 0, len(keywords)) + for _, kw := range keywords { + // ngram_token_size=2:单字进不了索引,交给 LIKE 那一级去兜 + if len([]rune(kw)) < 2 { + continue + } + terms = append(terms, `"`+strings.ReplaceAll(kw, `"`, ``)+`"`) + } + if len(terms) == 0 { + return nil, nil + } + expr := strings.Join(terms, " ") + + match := "MATCH(" + fulltextColumns + ") AGAINST (? IN BOOLEAN MODE)" + records := make([]*pb.DBEchoMeetRecord, 0, limit) + err := this.recentQuery(uid, startTs, endTs). + Where(match, expr). + // ⚠️ 排序必须用 gorm.Expr 把 expr 当参数传。字符串拼接的 Order(...) 带不了参数, + // 只能拼出 AGAINST ('')——相关度恒为 0,排序静默失效:看起来在按相关度排, + // 实际是乱序,而且不会有任何报错。 + Order(gorm.Expr(match+" DESC", expr)). + Order("creationtime DESC"). + Limit(limit). + Find(&records).Error + return records, err +} diff --git a/apps/services/modules/mcp/tool_memory.go b/apps/services/modules/mcp/tool_memory.go index ed37a0a0..f27cd301 100644 --- a/apps/services/modules/mcp/tool_memory.go +++ b/apps/services/modules/mcp/tool_memory.go @@ -65,7 +65,8 @@ func (this *tool_get_memory_items) Tool() mcp.Tool { } func (this *tool_get_memory_items) Handl(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - uid, err := this.module.ResolveUID(ctx, request.GetString("uid", "")) + uid, err := this.module.ResolveUIDWithToken(ctx, + request.GetString("uid", ""), request.GetString("auth_token", "")) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -135,7 +136,8 @@ func (this *tool_get_memory_stats) Tool() mcp.Tool { } func (this *tool_get_memory_stats) Handl(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - uid, err := this.module.ResolveUID(ctx, request.GetString("uid", "")) + uid, err := this.module.ResolveUIDWithToken(ctx, + request.GetString("uid", ""), request.GetString("auth_token", "")) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -246,7 +248,8 @@ func (this *tool_get_memory_report) Tool() mcp.Tool { } func (this *tool_get_memory_report) Handl(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - uid, err := this.module.ResolveUID(ctx, request.GetString("uid", "")) + uid, err := this.module.ResolveUIDWithToken(ctx, + request.GetString("uid", ""), request.GetString("auth_token", "")) if err != nil { return mcp.NewToolResultError(err.Error()), nil } diff --git a/apps/services/modules/user/api_getmcptoken.go b/apps/services/modules/user/api_getmcptoken.go new file mode 100644 index 00000000..ede1d689 --- /dev/null +++ b/apps/services/modules/user/api_getmcptoken.go @@ -0,0 +1,107 @@ +package user + +import ( + "time" + + "yunyan/comm" + "yunyan/pb" + + "github.com/golang-jwt/jwt/v5" +) + +/* +签发 MCP 访问令牌。 + +## 为什么需要它 + +EMAI 助手在百炼那边调我们的 MCP 工具(查会议纪要、查待办…)时,MCP 必须知道 +「这是哪个用户」。但这条链路上没有任何一处能天然带上身份: + + - 客户端连百炼只带**百炼的 API Key**,没有把用户自己的 JWT 交出去; + - 百炼调 MCP 时的 `Authorization` 头是控制台里**配死的**,所有用户共用一个值。 + +唯一可行的通道是 Start 指令的 `biz_params.user_defined_params`——协议文档明确说 +它是「透传给 agent 和 mcp 服务的参数」,会作为工具参数交到 MCP 手里。 + +⚠️ 实测确认过(2026-09-07):往 `biz_params` 里塞 `rag_options` 之类协议没定义的 +字段,服务端**静默丢弃**(对照组塞一个纯属虚构的字段,服务端同样回 Started), +所以只能用协议已有的这三个字段,不能自创。 + +## 为什么不直接用登录 JWT + +因为这条路要经过百炼(第三方)。登录 JWT 等价于账号的全部权限,一旦落到日志里 +或被第三方留存,用户的一切接口都能被调用。本令牌**只用于 MCP、且短时效**, +即使泄露,能做的也只有读该用户的会议纪要/待办这类只读工具。 + +## 为什么用独立密钥而不是 gateway 的 TokenKey + +同一把密钥签出来的令牌,gateway 也会认——那就等于又造了一把万能钥匙, +「限定用途」这件事就白做了。所以用 `McpTokenKey`,MCP 侧用同一个值校验, +gateway 不认识它。 +*/ + +// mcpTokenTTL 令牌有效期。 +// +// 取 2 小时是个折中:太短会让一次长对话中途失效(客户端得在会话里重签、 +// 而 Start 的参数是握手时定死的,改不了);太长则削弱了「短时效」本身的意义。 +// 客户端应在过期前重新申请,别等报错再补。 +const mcpTokenTTL = 2 * time.Hour + +// mcpTokenAudience 令牌用途标记。MCP 侧必须校验它,否则将来若有别的地方 +// 也用 McpTokenKey 签东西,那些令牌会被 MCP 一并接受。 +const mcpTokenAudience = "mcp" + +// @Summary 申请 MCP 访问令牌 +// @Description 签发短时效、仅限 MCP 工具调用的令牌,供客户端放进百炼会话的 biz_params 透传 +// @Tags User +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user body pb.UserGetMcpTokenReq true "请求数据" +// @Success 200 {object} comm.HttpResult{data=pb.UserGetMcpTokenResp} "响应数据" +// @Router /api/home/user_getmcptoken [post] +func (this *apiComp) GetMcpToken(session comm.IUserSession, req *pb.UserGetMcpTokenReq) (resp *pb.UserGetMcpTokenResp, errdata *pb.ErrorData) { + uid := session.GetUserId() + if uid == "" { + errdata = &pb.ErrorData{ + Code: pb.ErrorCode_NoLogin, + Message: "未登录", + } + return + } + + key := this.module.options.McpTokenKey + if key == "" { + // 没配就直说,不要签一个空密钥的令牌。 + // 空密钥签出来的东西谁都能伪造,比功能不可用严重得多。 + errdata = &pb.ErrorData{ + Code: pb.ErrorCode_SystemError, + Message: "服务端未配置 McpTokenKey", + } + return + } + + expiresAt := time.Now().Add(mcpTokenTTL) + // 与 gateway/MCP 同一套口径:uid 放在 RegisteredClaims 的 ID 字段。 + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.RegisteredClaims{ + ID: uid, + Audience: jwt.ClaimStrings{mcpTokenAudience}, + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + }) + signed, err := token.SignedString([]byte(key)) + if err != nil { + this.module.Errorf("GetMcpToken uid:%s 签发失败 err:%v", uid, err) + errdata = &pb.ErrorData{ + Code: pb.ErrorCode_SystemError, + Message: "令牌签发失败", + } + return + } + + resp = &pb.UserGetMcpTokenResp{ + Token: signed, + Expiresat: expiresAt.Unix(), + } + return +} diff --git a/apps/services/modules/user/options.go b/apps/services/modules/user/options.go index 89ba1391..4cd275f0 100644 --- a/apps/services/modules/user/options.go +++ b/apps/services/modules/user/options.go @@ -12,7 +12,12 @@ type ( } Options struct { modules.Options - TokenKey string + TokenKey string + // McpTokenKey 签发 MCP 访问令牌用,**必须与 mcp 服务的同名配置一致**。 + // ⚠️ 刻意不复用 TokenKey:同一把密钥签出来的令牌 gateway 也会认, + // 那 MCP 令牌「只能用于 MCP」这个限制就形同虚设了。留空时 + // user_getmcptoken 直接报错,不会退化成用 TokenKey 签。 + McpTokenKey string CBCKey string AvatarUrl string //头像地址 UserGroup int //用户分组 diff --git a/apps/services/pb/user_msg.pb.go b/apps/services/pb/user_msg.pb.go index 00aa5d3f..728ceb2d 100644 --- a/apps/services/pb/user_msg.pb.go +++ b/apps/services/pb/user_msg.pb.go @@ -3063,6 +3063,106 @@ func (x *UserGetUploadUrlResp) GetExpiresec() int64 { return 0 } +// 申请 MCP 访问令牌 +// +// EMAI 助手在百炼那边调我们的 MCP 工具时,需要知道「这是哪个用户」。 +// 但这条路上没有任何地方能带用户身份:客户端连百炼只带百炼的 API Key, +// 百炼调 MCP 的 Authorization 头是控制台里配死的、所有用户共用。 +// 唯一可行的通道是 Start 指令的 biz_params.user_defined_params(协议里明确说 +// 「透传给 agent 和 mcp 服务的参数」),它会作为工具参数交到 MCP 手上。 +// +// ⚠️ 因此这里签发的是**短时效、仅限 MCP 用途**的令牌,绝不能直接把登录 JWT +// 丢进去——那东西等价于账号的全部权限,而这条链路要经过百炼(第三方)。 +// 本令牌即使泄露,也只能读该用户的会议纪要/待办这类只读工具。 +// 路由 user_getmcptoken +type UserGetMcpTokenReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserGetMcpTokenReq) Reset() { + *x = UserGetMcpTokenReq{} + mi := &file_user_user_msg_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserGetMcpTokenReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserGetMcpTokenReq) ProtoMessage() {} + +func (x *UserGetMcpTokenReq) ProtoReflect() protoreflect.Message { + mi := &file_user_user_msg_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserGetMcpTokenReq.ProtoReflect.Descriptor instead. +func (*UserGetMcpTokenReq) Descriptor() ([]byte, []int) { + return file_user_user_msg_proto_rawDescGZIP(), []int{55} +} + +type UserGetMcpTokenResp struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token"` //@go_tags(`json:"token"`) 放进 biz_params.user_defined_params 交给百炼 + Expiresat int64 `protobuf:"varint,2,opt,name=expiresat,proto3" json:"expiresat"` //@go_tags(`json:"expiresat"`) 过期时刻(unix秒),客户端据此提前续签 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserGetMcpTokenResp) Reset() { + *x = UserGetMcpTokenResp{} + mi := &file_user_user_msg_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserGetMcpTokenResp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserGetMcpTokenResp) ProtoMessage() {} + +func (x *UserGetMcpTokenResp) ProtoReflect() protoreflect.Message { + mi := &file_user_user_msg_proto_msgTypes[56] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserGetMcpTokenResp.ProtoReflect.Descriptor instead. +func (*UserGetMcpTokenResp) Descriptor() ([]byte, []int) { + return file_user_user_msg_proto_rawDescGZIP(), []int{56} +} + +func (x *UserGetMcpTokenResp) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *UserGetMcpTokenResp) GetExpiresat() int64 { + if x != nil { + return x.Expiresat + } + return 0 +} + var File_user_user_msg_proto protoreflect.FileDescriptor const file_user_user_msg_proto_rawDesc = "" + @@ -3301,7 +3401,11 @@ const file_user_user_msg_proto_rawDesc = "" + "\x03url\x18\x02 \x01(\tR\x03url\x12\x10\n" + "\x03key\x18\x03 \x01(\tR\x03key\x12 \n" + "\vcontenttype\x18\x04 \x01(\tR\vcontenttype\x12\x1c\n" + - "\texpiresec\x18\x05 \x01(\x03R\texpiresecB\x06Z\x04.;pbb\x06proto3" + "\texpiresec\x18\x05 \x01(\x03R\texpiresec\"\x14\n" + + "\x12UserGetMcpTokenReq\"I\n" + + "\x13UserGetMcpTokenResp\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" + + "\texpiresat\x18\x02 \x01(\x03R\texpiresatB\x06Z\x04.;pbb\x06proto3" var ( file_user_user_msg_proto_rawDescOnce sync.Once @@ -3315,7 +3419,7 @@ func file_user_user_msg_proto_rawDescGZIP() []byte { return file_user_user_msg_proto_rawDescData } -var file_user_user_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 60) +var file_user_user_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 62) var file_user_user_msg_proto_goTypes = []any{ (*UserSginReq)(nil), // 0: UserSginReq (*UserSginResp)(nil), // 1: UserSginResp @@ -3372,53 +3476,55 @@ var file_user_user_msg_proto_goTypes = []any{ (*UserTranslateResp)(nil), // 52: UserTranslateResp (*UserGetUploadUrlReq)(nil), // 53: UserGetUploadUrlReq (*UserGetUploadUrlResp)(nil), // 54: UserGetUploadUrlResp - nil, // 55: UserGetAppConfigResp.EnvEntry - nil, // 56: UserGetAppConfigResp.McpsEntry - nil, // 57: UserPushActionReq.ActionsEntry - nil, // 58: UserUsagesReq.UsagesEntry - nil, // 59: UserUsagesResp.UsagesEntry - (SginTyoe)(0), // 60: SginTyoe - (UserChannel)(0), // 61: UserChannel - (*DBUser)(nil), // 62: DBUser - (*DBUserDivice)(nil), // 63: DBUserDivice - (MCPServerType)(0), // 64: MCPServerType - (*DBAgent)(nil), // 65: DBAgent - (*DBProduct)(nil), // 66: DBProduct - (*DBProductVersion)(nil), // 67: DBProductVersion - (*DBChannelApp)(nil), // 68: DBChannelApp - (UsageType)(0), // 69: UsageType - (*DBWakeupVoice)(nil), // 70: DBWakeupVoice - (*DBUserStatistics)(nil), // 71: DBUserStatistics - (*DBUuserAppSetting)(nil), // 72: DBUuserAppSetting + (*UserGetMcpTokenReq)(nil), // 55: UserGetMcpTokenReq + (*UserGetMcpTokenResp)(nil), // 56: UserGetMcpTokenResp + nil, // 57: UserGetAppConfigResp.EnvEntry + nil, // 58: UserGetAppConfigResp.McpsEntry + nil, // 59: UserPushActionReq.ActionsEntry + nil, // 60: UserUsagesReq.UsagesEntry + nil, // 61: UserUsagesResp.UsagesEntry + (SginTyoe)(0), // 62: SginTyoe + (UserChannel)(0), // 63: UserChannel + (*DBUser)(nil), // 64: DBUser + (*DBUserDivice)(nil), // 65: DBUserDivice + (MCPServerType)(0), // 66: MCPServerType + (*DBAgent)(nil), // 67: DBAgent + (*DBProduct)(nil), // 68: DBProduct + (*DBProductVersion)(nil), // 69: DBProductVersion + (*DBChannelApp)(nil), // 70: DBChannelApp + (UsageType)(0), // 71: UsageType + (*DBWakeupVoice)(nil), // 72: DBWakeupVoice + (*DBUserStatistics)(nil), // 73: DBUserStatistics + (*DBUuserAppSetting)(nil), // 74: DBUuserAppSetting } var file_user_user_msg_proto_depIdxs = []int32{ - 60, // 0: UserSginReq.stype:type_name -> SginTyoe - 61, // 1: UserSginReq.channel:type_name -> UserChannel - 62, // 2: UserSginResp.user:type_name -> DBUser - 63, // 3: UserSginResp.devices:type_name -> DBUserDivice - 61, // 4: UserGetInfoReq.channel:type_name -> UserChannel - 62, // 5: UserGetInfoResp.user:type_name -> DBUser - 63, // 6: UserGetInfoResp.devices:type_name -> DBUserDivice - 63, // 7: UserBindDeviceResp.device:type_name -> DBUserDivice - 63, // 8: UserBindAuthCodeResp.device:type_name -> DBUserDivice - 64, // 9: McpServer.type:type_name -> MCPServerType - 55, // 10: UserGetAppConfigResp.env:type_name -> UserGetAppConfigResp.EnvEntry - 65, // 11: UserGetAppConfigResp.agents:type_name -> DBAgent - 56, // 12: UserGetAppConfigResp.mcps:type_name -> UserGetAppConfigResp.McpsEntry - 66, // 13: UserGetAppConfigResp.products:type_name -> DBProduct - 66, // 14: UserGetProductResp.product:type_name -> DBProduct - 67, // 15: UserGetProductVersionsResp.versions:type_name -> DBProductVersion - 68, // 16: UserGetChannelAppsResp.apps:type_name -> DBChannelApp - 68, // 17: UserGetChannelAppResp.app:type_name -> DBChannelApp - 57, // 18: UserPushActionReq.actions:type_name -> UserPushActionReq.ActionsEntry - 63, // 19: UserGetDevicesResp.devices:type_name -> DBUserDivice - 69, // 20: UserUsagesReq.usagetype:type_name -> UsageType - 58, // 21: UserUsagesReq.usages:type_name -> UserUsagesReq.UsagesEntry - 59, // 22: UserUsagesResp.usages:type_name -> UserUsagesResp.UsagesEntry - 70, // 23: UserGetWakeupVoicesResp.voices:type_name -> DBWakeupVoice - 71, // 24: UserGetStatisticsResp.statistics:type_name -> DBUserStatistics - 72, // 25: UserGetAppSettingResp.setting:type_name -> DBUuserAppSetting - 72, // 26: UserUpdateAppSettingReq.setting:type_name -> DBUuserAppSetting + 62, // 0: UserSginReq.stype:type_name -> SginTyoe + 63, // 1: UserSginReq.channel:type_name -> UserChannel + 64, // 2: UserSginResp.user:type_name -> DBUser + 65, // 3: UserSginResp.devices:type_name -> DBUserDivice + 63, // 4: UserGetInfoReq.channel:type_name -> UserChannel + 64, // 5: UserGetInfoResp.user:type_name -> DBUser + 65, // 6: UserGetInfoResp.devices:type_name -> DBUserDivice + 65, // 7: UserBindDeviceResp.device:type_name -> DBUserDivice + 65, // 8: UserBindAuthCodeResp.device:type_name -> DBUserDivice + 66, // 9: McpServer.type:type_name -> MCPServerType + 57, // 10: UserGetAppConfigResp.env:type_name -> UserGetAppConfigResp.EnvEntry + 67, // 11: UserGetAppConfigResp.agents:type_name -> DBAgent + 58, // 12: UserGetAppConfigResp.mcps:type_name -> UserGetAppConfigResp.McpsEntry + 68, // 13: UserGetAppConfigResp.products:type_name -> DBProduct + 68, // 14: UserGetProductResp.product:type_name -> DBProduct + 69, // 15: UserGetProductVersionsResp.versions:type_name -> DBProductVersion + 70, // 16: UserGetChannelAppsResp.apps:type_name -> DBChannelApp + 70, // 17: UserGetChannelAppResp.app:type_name -> DBChannelApp + 59, // 18: UserPushActionReq.actions:type_name -> UserPushActionReq.ActionsEntry + 65, // 19: UserGetDevicesResp.devices:type_name -> DBUserDivice + 71, // 20: UserUsagesReq.usagetype:type_name -> UsageType + 60, // 21: UserUsagesReq.usages:type_name -> UserUsagesReq.UsagesEntry + 61, // 22: UserUsagesResp.usages:type_name -> UserUsagesResp.UsagesEntry + 72, // 23: UserGetWakeupVoicesResp.voices:type_name -> DBWakeupVoice + 73, // 24: UserGetStatisticsResp.statistics:type_name -> DBUserStatistics + 74, // 25: UserGetAppSettingResp.setting:type_name -> DBUuserAppSetting + 74, // 26: UserUpdateAppSettingReq.setting:type_name -> DBUuserAppSetting 19, // 27: UserGetAppConfigResp.McpsEntry.value:type_name -> McpServer 28, // [28:28] is the sub-list for method output_type 28, // [28:28] is the sub-list for method input_type @@ -3441,7 +3547,7 @@ func file_user_user_msg_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_user_msg_proto_rawDesc), len(file_user_user_msg_proto_rawDesc)), NumEnums: 0, - NumMessages: 60, + NumMessages: 62, NumExtensions: 0, NumServices: 0, }, diff --git a/deploy/app/confs/home.yaml.example b/deploy/app/confs/home.yaml.example index 048dacd9..89fc61f3 100644 --- a/deploy/app/confs/home.yaml.example +++ b/deploy/app/confs/home.yaml.example @@ -143,6 +143,11 @@ modules: Debug: ${LOG_DEBUG:-false} AvatarUrl: "https://api.ideapsound.com/avatar/" TokenKey: "${GATEWAY_TOKEN_KEY:-}" + # ⚠️ 签发 MCP 会话令牌用,必须与 mcp.yaml 的 McpTokenKey 同值 + # (两边都读 .env 的 MCP_TOKEN_KEY)。留空则 user_getmcptoken 直接报错, + # EMAI 那边查会议纪要/待办的 MCP 工具会全部返回「身份校验失败」。 + # 刻意不复用 TokenKey:这个令牌要经过百炼(第三方)之手。 + McpTokenKey: "${MCP_TOKEN_KEY:-}" CBCKey: "" allhelp: max_summary_process: 5 diff --git a/deploy/app/confs/mcp.yaml.example b/deploy/app/confs/mcp.yaml.example index 333dd211..7eab63a3 100644 --- a/deploy/app/confs/mcp.yaml.example +++ b/deploy/app/confs/mcp.yaml.example @@ -41,12 +41,19 @@ modules: # 靠它自己校验 JWT。留空时这些工具一律拒绝服务——配置漏了导致鉴权静默失效 # 比工具不可用严重得多。 TokenKey: "${GATEWAY_TOKEN_KEY:-}" + # ⚠️ 校验 user_getmcptoken 签发的 MCP 令牌,必须与 home.yaml 里 user 模块的 + # McpTokenKey 是同一个值(两边都读 .env 的 MCP_TOKEN_KEY)。 + # 刻意不复用 TokenKey:MCP 令牌要经过百炼(第三方)之手, + # 不能用能开全站的那把钥匙去签。留空则带令牌的调用一律被拒。 + McpTokenKey: "${MCP_TOKEN_KEY:-}" Groups: GLOBAL: # 记忆中心(拾忆)只读工具:回答「我明天有什么事」「这个月花了多少」 - get_memory_items - get_memory_stats - get_memory_report + # 会议纪要只读工具:回答「我上次开会定了啥」「关于XX项目的会议说了什么」 + - search_meeting_notes - hefeng_weather - music_search - music_play diff --git a/deploy/app/env/env.example b/deploy/app/env/env.example index 4f244d2d..84b0c310 100644 --- a/deploy/app/env/env.example +++ b/deploy/app/env/env.example @@ -65,6 +65,11 @@ ANALYZE_REGION=1 # ── 业务杂项 ── LOG_DEBUG=true GATEWAY_TOKEN_KEY=dev-change-me +# MCP 会话令牌的签名密钥(user 模块签发、mcp 服务校验,两边同值)。 +# ⚠️ 必须与 GATEWAY_TOKEN_KEY **不同**:这个令牌要经过百炼(第三方)之手, +# 用能开全站的那把钥匙去签,等于又造了一把万能钥匙。留空则 EMAI 里 +# 查会议纪要/待办的 MCP 工具全部不可用。 +MCP_TOKEN_KEY=dev-change-me-mcp # v2 配置接口响应加密 AES key(16/24/32 字节),须与 Flutter ApiCrypto._key 完全一致;留空=不加密 GATEWAY_ENCRYPT_KEY= API_ADMIN_ACCOUNT=admin