Browse Source

MCP 加会议纪要检索工具;用户类工具改用专用短时令牌鉴权

EMAI 助手此前答不了「我上次开会讲了什么」——纪要在服务端,助手拿不到。
补 search_meeting_notes 工具,配套解决身份怎么传。

身份链路(关键取舍):
百炼是第三方,MCP 请求经它转发,所以不能把网关那把能开全站的 JWT 交出去。
新增 MCP 专用令牌:user_getmcptoken 由 home 签发(uid 从网关解出的会话取,
客户端说了不算),aud=mcp、TTL 2h,用独立的 MCP_TOKEN_KEY 签。
即使泄漏,能做的也只有读这一个用户的只读数据,且两小时后失效。

⚠️ 裸 uid 参数已不再被信任:谁都能填别人的 uid。ResolveUIDWithToken 要么拿到
Authorization 里的登录态,要么拿到这张令牌,两者都没有就直接拒。
auth_mcptoken_test.go 守着这条底线(含 aud 不符、过期、换密钥三种伪造)。

⚠️ 令牌必须按工具名嵌套传:真机实测百炼**不会**把 biz_params.user_defined_params
平铺进工具参数——那次调用服务端只收到 [limit]。按 {"工具名": {"auth_token": ...}}
嵌套才生效。这个形状文档没写死,是试出来的。

检索是三级回退:全文索引(按相关度排序) → LIKE(兜单字,ngram_token_size=2 时
单字进不了索引) → 最近几条。最后一级不能省:模型常把「最近的会议纪要」整句
当关键词传,纪要正文里不会有「最近」二字,LIKE 全落空,用户看到的就是
「查不到您的会议纪要」而库里明明有。回退结果带 fallback 标记,免得模型把
「最近一次会议」当成「你问的那次会议」来答。

文件头注释里记了三个「该换向量/知识库方案」的触发条件,量大了照着判断。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
48e31cf605
  1. 19
      apps/proto/user/user_msg.proto
  2. 107
      apps/services/modules/mcp/auth.go
  3. 138
      apps/services/modules/mcp/auth_mcptoken_test.go
  4. 3
      apps/services/modules/mcp/module.go
  5. 4
      apps/services/modules/mcp/options.go
  6. 2
      apps/services/modules/mcp/tool_allhelp_task.go
  7. 2
      apps/services/modules/mcp/tool_cancel_user_task.go
  8. 2
      apps/services/modules/mcp/tool_get_user_tasks.go
  9. 401
      apps/services/modules/mcp/tool_meeting.go
  10. 9
      apps/services/modules/mcp/tool_memory.go
  11. 107
      apps/services/modules/user/api_getmcptoken.go
  12. 5
      apps/services/modules/user/options.go
  13. 202
      apps/services/pb/user_msg.pb.go
  14. 5
      deploy/app/confs/home.yaml.example
  15. 7
      deploy/app/confs/mcp.yaml.example
  16. 5
      deploy/app/env/env.example

19
apps/proto/user/user_msg.proto

@ -362,3 +362,22 @@ message UserGetUploadUrlResp{
string contenttype = 4; //⚠️ PUT 时必须带这个 Content-Type,它计入了签名,不一致会 403 string contenttype = 4; //⚠️ PUT 时必须带这个 Content-Type,它计入了签名,不一致会 403
int64 expiresec = 5; //预签名有效期(秒),超时要重新申请 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秒),客户端据此提前续签
}

107
apps/services/modules/mcp/auth.go

@ -82,20 +82,40 @@ func parseUID(tokenString string, secretKey []byte) (string, error) {
return claims.ID, nil return claims.ID, nil
} }
// mcpTokenAudience 与 user 模块签发时写入的用途标记一致。
// 必须校验:将来若有别处也用 McpTokenKey 签东西,不校验就会被一并接受。
const mcpTokenAudience = "mcp"
// ResolveUID 决定这次工具调用该用哪个 uid。 // ResolveUID 决定这次工具调用该用哪个 uid。
// //
// argUID 是模型传上来的参数,只用于「与会话 uid 比对」,绝不作为回退值。 // argUID 是模型传上来的参数,只用于「与会话 uid 比对」,绝不作为回退值。
func (this *Mcp) ResolveUID(ctx context.Context, argUID string) (string, error) { func (this *Mcp) ResolveUID(ctx context.Context, argUID string) (string, error) {
key := this.options.TokenKey return this.ResolveUIDWithToken(ctx, argUID, "")
if key == "" {
// 没配 TokenKey 说明部署时漏了 GATEWAY_TOKEN_KEY。
// 这时**拒绝所有用户类工具**而不是放行:配置缺失导致鉴权静默失效
// 是最糟的一种失败方式。
return "", fmt.Errorf("服务端未配置 TokenKey,用户数据类工具不可用")
} }
uid, err := parseUID(tokenFromContext(ctx), []byte(key))
// 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 { if err != nil {
return "", fmt.Errorf("身份校验失败: %v", err) return "", err
} }
argUID = strings.TrimSpace(argUID) argUID = strings.TrimSpace(argUID)
if argUID != "" && argUID != uid { if argUID != "" && argUID != uid {
@ -103,3 +123,74 @@ func (this *Mcp) ResolveUID(ctx context.Context, argUID string) (string, error)
} }
return uid, nil 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
}

138
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("密钥没配却放行了")
}
})
}

3
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_stats))
this.RegisterComp(new(tool_get_memory_report)) this.RegisterComp(new(tool_get_memory_report))
// 会议纪要(只读):回答「我上次开会定了啥」
this.RegisterComp(new(tool_search_meeting_notes))
this.RegisterComp(new(tool_allhelp_task)) this.RegisterComp(new(tool_allhelp_task))
this.RegisterComp(new(tool_get_user_tasks)) this.RegisterComp(new(tool_get_user_tasks))
this.RegisterComp(new(tool_cancel_user_task)) this.RegisterComp(new(tool_cancel_user_task))

4
apps/services/modules/mcp/options.go

@ -19,6 +19,10 @@ type (
// 留空时所有用户数据类工具一律拒绝服务 —— 配置漏了导致鉴权静默失效 // 留空时所有用户数据类工具一律拒绝服务 —— 配置漏了导致鉴权静默失效
// 比工具不可用严重得多。 // 比工具不可用严重得多。
TokenKey string TokenKey string
// McpTokenKey 校验 user_getmcptoken 签发的 MCP 令牌用,
// **必须与 user 模块的同名配置一致**。它与 TokenKey 是两把不同的钥匙:
// MCP 令牌要经过百炼(第三方)之手,不能用能开全站的那把去签。
McpTokenKey string
} }
) )

2
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, // Authorization 里的 JWT 解出来(见 auth.go)。此前这里直接信任模型填的 uid,
// 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。 // 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。
argUID := request.GetString("uid", "") 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }

2
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, // Authorization 里的 JWT 解出来(见 auth.go)。此前这里直接信任模型填的 uid,
// 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。 // 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。
argUID := request.GetString("uid", "") 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }

2
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, // Authorization 里的 JWT 解出来(见 auth.go)。此前这里直接信任模型填的 uid,
// 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。 // 而 MCP 是公网可达且无鉴权的独立服务,等于谁都能读改任意人的数据。
argUID := request.GetString("uid", "") 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }

401
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
}

9
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) { 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), 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) { 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), 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) { 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 { if err != nil {
return mcp.NewToolResultError(err.Error()), nil return mcp.NewToolResultError(err.Error()), nil
} }

107
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
}

5
apps/services/modules/user/options.go

@ -13,6 +13,11 @@ type (
Options struct { Options struct {
modules.Options modules.Options
TokenKey string TokenKey string
// McpTokenKey 签发 MCP 访问令牌用,**必须与 mcp 服务的同名配置一致**。
// ⚠️ 刻意不复用 TokenKey:同一把密钥签出来的令牌 gateway 也会认,
// 那 MCP 令牌「只能用于 MCP」这个限制就形同虚设了。留空时
// user_getmcptoken 直接报错,不会退化成用 TokenKey 签。
McpTokenKey string
CBCKey string CBCKey string
AvatarUrl string //头像地址 AvatarUrl string //头像地址
UserGroup int //用户分组 UserGroup int //用户分组

202
apps/services/pb/user_msg.pb.go

@ -3063,6 +3063,106 @@ func (x *UserGetUploadUrlResp) GetExpiresec() int64 {
return 0 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 var File_user_user_msg_proto protoreflect.FileDescriptor
const file_user_user_msg_proto_rawDesc = "" + 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" + "\x03url\x18\x02 \x01(\tR\x03url\x12\x10\n" +
"\x03key\x18\x03 \x01(\tR\x03key\x12 \n" + "\x03key\x18\x03 \x01(\tR\x03key\x12 \n" +
"\vcontenttype\x18\x04 \x01(\tR\vcontenttype\x12\x1c\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 ( var (
file_user_user_msg_proto_rawDescOnce sync.Once 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 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{ var file_user_user_msg_proto_goTypes = []any{
(*UserSginReq)(nil), // 0: UserSginReq (*UserSginReq)(nil), // 0: UserSginReq
(*UserSginResp)(nil), // 1: UserSginResp (*UserSginResp)(nil), // 1: UserSginResp
@ -3372,53 +3476,55 @@ var file_user_user_msg_proto_goTypes = []any{
(*UserTranslateResp)(nil), // 52: UserTranslateResp (*UserTranslateResp)(nil), // 52: UserTranslateResp
(*UserGetUploadUrlReq)(nil), // 53: UserGetUploadUrlReq (*UserGetUploadUrlReq)(nil), // 53: UserGetUploadUrlReq
(*UserGetUploadUrlResp)(nil), // 54: UserGetUploadUrlResp (*UserGetUploadUrlResp)(nil), // 54: UserGetUploadUrlResp
nil, // 55: UserGetAppConfigResp.EnvEntry (*UserGetMcpTokenReq)(nil), // 55: UserGetMcpTokenReq
nil, // 56: UserGetAppConfigResp.McpsEntry (*UserGetMcpTokenResp)(nil), // 56: UserGetMcpTokenResp
nil, // 57: UserPushActionReq.ActionsEntry nil, // 57: UserGetAppConfigResp.EnvEntry
nil, // 58: UserUsagesReq.UsagesEntry nil, // 58: UserGetAppConfigResp.McpsEntry
nil, // 59: UserUsagesResp.UsagesEntry nil, // 59: UserPushActionReq.ActionsEntry
(SginTyoe)(0), // 60: SginTyoe nil, // 60: UserUsagesReq.UsagesEntry
(UserChannel)(0), // 61: UserChannel nil, // 61: UserUsagesResp.UsagesEntry
(*DBUser)(nil), // 62: DBUser (SginTyoe)(0), // 62: SginTyoe
(*DBUserDivice)(nil), // 63: DBUserDivice (UserChannel)(0), // 63: UserChannel
(MCPServerType)(0), // 64: MCPServerType (*DBUser)(nil), // 64: DBUser
(*DBAgent)(nil), // 65: DBAgent (*DBUserDivice)(nil), // 65: DBUserDivice
(*DBProduct)(nil), // 66: DBProduct (MCPServerType)(0), // 66: MCPServerType
(*DBProductVersion)(nil), // 67: DBProductVersion (*DBAgent)(nil), // 67: DBAgent
(*DBChannelApp)(nil), // 68: DBChannelApp (*DBProduct)(nil), // 68: DBProduct
(UsageType)(0), // 69: UsageType (*DBProductVersion)(nil), // 69: DBProductVersion
(*DBWakeupVoice)(nil), // 70: DBWakeupVoice (*DBChannelApp)(nil), // 70: DBChannelApp
(*DBUserStatistics)(nil), // 71: DBUserStatistics (UsageType)(0), // 71: UsageType
(*DBUuserAppSetting)(nil), // 72: DBUuserAppSetting (*DBWakeupVoice)(nil), // 72: DBWakeupVoice
(*DBUserStatistics)(nil), // 73: DBUserStatistics
(*DBUuserAppSetting)(nil), // 74: DBUuserAppSetting
} }
var file_user_user_msg_proto_depIdxs = []int32{ var file_user_user_msg_proto_depIdxs = []int32{
60, // 0: UserSginReq.stype:type_name -> SginTyoe 62, // 0: UserSginReq.stype:type_name -> SginTyoe
61, // 1: UserSginReq.channel:type_name -> UserChannel 63, // 1: UserSginReq.channel:type_name -> UserChannel
62, // 2: UserSginResp.user:type_name -> DBUser 64, // 2: UserSginResp.user:type_name -> DBUser
63, // 3: UserSginResp.devices:type_name -> DBUserDivice 65, // 3: UserSginResp.devices:type_name -> DBUserDivice
61, // 4: UserGetInfoReq.channel:type_name -> UserChannel 63, // 4: UserGetInfoReq.channel:type_name -> UserChannel
62, // 5: UserGetInfoResp.user:type_name -> DBUser 64, // 5: UserGetInfoResp.user:type_name -> DBUser
63, // 6: UserGetInfoResp.devices:type_name -> DBUserDivice 65, // 6: UserGetInfoResp.devices:type_name -> DBUserDivice
63, // 7: UserBindDeviceResp.device:type_name -> DBUserDivice 65, // 7: UserBindDeviceResp.device:type_name -> DBUserDivice
63, // 8: UserBindAuthCodeResp.device:type_name -> DBUserDivice 65, // 8: UserBindAuthCodeResp.device:type_name -> DBUserDivice
64, // 9: McpServer.type:type_name -> MCPServerType 66, // 9: McpServer.type:type_name -> MCPServerType
55, // 10: UserGetAppConfigResp.env:type_name -> UserGetAppConfigResp.EnvEntry 57, // 10: UserGetAppConfigResp.env:type_name -> UserGetAppConfigResp.EnvEntry
65, // 11: UserGetAppConfigResp.agents:type_name -> DBAgent 67, // 11: UserGetAppConfigResp.agents:type_name -> DBAgent
56, // 12: UserGetAppConfigResp.mcps:type_name -> UserGetAppConfigResp.McpsEntry 58, // 12: UserGetAppConfigResp.mcps:type_name -> UserGetAppConfigResp.McpsEntry
66, // 13: UserGetAppConfigResp.products:type_name -> DBProduct 68, // 13: UserGetAppConfigResp.products:type_name -> DBProduct
66, // 14: UserGetProductResp.product:type_name -> DBProduct 68, // 14: UserGetProductResp.product:type_name -> DBProduct
67, // 15: UserGetProductVersionsResp.versions:type_name -> DBProductVersion 69, // 15: UserGetProductVersionsResp.versions:type_name -> DBProductVersion
68, // 16: UserGetChannelAppsResp.apps:type_name -> DBChannelApp 70, // 16: UserGetChannelAppsResp.apps:type_name -> DBChannelApp
68, // 17: UserGetChannelAppResp.app:type_name -> DBChannelApp 70, // 17: UserGetChannelAppResp.app:type_name -> DBChannelApp
57, // 18: UserPushActionReq.actions:type_name -> UserPushActionReq.ActionsEntry 59, // 18: UserPushActionReq.actions:type_name -> UserPushActionReq.ActionsEntry
63, // 19: UserGetDevicesResp.devices:type_name -> DBUserDivice 65, // 19: UserGetDevicesResp.devices:type_name -> DBUserDivice
69, // 20: UserUsagesReq.usagetype:type_name -> UsageType 71, // 20: UserUsagesReq.usagetype:type_name -> UsageType
58, // 21: UserUsagesReq.usages:type_name -> UserUsagesReq.UsagesEntry 60, // 21: UserUsagesReq.usages:type_name -> UserUsagesReq.UsagesEntry
59, // 22: UserUsagesResp.usages:type_name -> UserUsagesResp.UsagesEntry 61, // 22: UserUsagesResp.usages:type_name -> UserUsagesResp.UsagesEntry
70, // 23: UserGetWakeupVoicesResp.voices:type_name -> DBWakeupVoice 72, // 23: UserGetWakeupVoicesResp.voices:type_name -> DBWakeupVoice
71, // 24: UserGetStatisticsResp.statistics:type_name -> DBUserStatistics 73, // 24: UserGetStatisticsResp.statistics:type_name -> DBUserStatistics
72, // 25: UserGetAppSettingResp.setting:type_name -> DBUuserAppSetting 74, // 25: UserGetAppSettingResp.setting:type_name -> DBUuserAppSetting
72, // 26: UserUpdateAppSettingReq.setting:type_name -> DBUuserAppSetting 74, // 26: UserUpdateAppSettingReq.setting:type_name -> DBUuserAppSetting
19, // 27: UserGetAppConfigResp.McpsEntry.value:type_name -> McpServer 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 output_type
28, // [28:28] is the sub-list for method input_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(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_user_msg_proto_rawDesc), len(file_user_user_msg_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_user_msg_proto_rawDesc), len(file_user_user_msg_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 60, NumMessages: 62,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

5
deploy/app/confs/home.yaml.example

@ -143,6 +143,11 @@ modules:
Debug: ${LOG_DEBUG:-false} Debug: ${LOG_DEBUG:-false}
AvatarUrl: "https://api.ideapsound.com/avatar/" AvatarUrl: "https://api.ideapsound.com/avatar/"
TokenKey: "${GATEWAY_TOKEN_KEY:-}" TokenKey: "${GATEWAY_TOKEN_KEY:-}"
# ⚠️ 签发 MCP 会话令牌用,必须与 mcp.yaml 的 McpTokenKey 同值
# (两边都读 .env 的 MCP_TOKEN_KEY)。留空则 user_getmcptoken 直接报错,
# EMAI 那边查会议纪要/待办的 MCP 工具会全部返回「身份校验失败」。
# 刻意不复用 TokenKey:这个令牌要经过百炼(第三方)之手。
McpTokenKey: "${MCP_TOKEN_KEY:-}"
CBCKey: "" CBCKey: ""
allhelp: allhelp:
max_summary_process: 5 max_summary_process: 5

7
deploy/app/confs/mcp.yaml.example

@ -41,12 +41,19 @@ modules:
# 靠它自己校验 JWT。留空时这些工具一律拒绝服务——配置漏了导致鉴权静默失效 # 靠它自己校验 JWT。留空时这些工具一律拒绝服务——配置漏了导致鉴权静默失效
# 比工具不可用严重得多。 # 比工具不可用严重得多。
TokenKey: "${GATEWAY_TOKEN_KEY:-}" TokenKey: "${GATEWAY_TOKEN_KEY:-}"
# ⚠️ 校验 user_getmcptoken 签发的 MCP 令牌,必须与 home.yaml 里 user 模块的
# McpTokenKey 是同一个值(两边都读 .env 的 MCP_TOKEN_KEY)。
# 刻意不复用 TokenKey:MCP 令牌要经过百炼(第三方)之手,
# 不能用能开全站的那把钥匙去签。留空则带令牌的调用一律被拒。
McpTokenKey: "${MCP_TOKEN_KEY:-}"
Groups: Groups:
GLOBAL: GLOBAL:
# 记忆中心(拾忆)只读工具:回答「我明天有什么事」「这个月花了多少」 # 记忆中心(拾忆)只读工具:回答「我明天有什么事」「这个月花了多少」
- get_memory_items - get_memory_items
- get_memory_stats - get_memory_stats
- get_memory_report - get_memory_report
# 会议纪要只读工具:回答「我上次开会定了啥」「关于XX项目的会议说了什么」
- search_meeting_notes
- hefeng_weather - hefeng_weather
- music_search - music_search
- music_play - music_play

5
deploy/app/env/env.example

@ -65,6 +65,11 @@ ANALYZE_REGION=1
# ── 业务杂项 ── # ── 业务杂项 ──
LOG_DEBUG=true LOG_DEBUG=true
GATEWAY_TOKEN_KEY=dev-change-me 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 完全一致;留空=不加密 # v2 配置接口响应加密 AES key(16/24/32 字节),须与 Flutter ApiCrypto._key 完全一致;留空=不加密
GATEWAY_ENCRYPT_KEY= GATEWAY_ENCRYPT_KEY=
API_ADMIN_ACCOUNT=admin API_ADMIN_ACCOUNT=admin

Loading…
Cancel
Save