Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f00b83e86 | |||
| 97450f0739 |
@@ -88,6 +88,8 @@ go run ./cmd/server -config config.json -addr :8080
|
||||
| 短信 | `SMS()` / `MustSMS()` | SendSMS / SendSMSAsync |
|
||||
| Excel | `Excel()` | 每次新建导出器 |
|
||||
| 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 |
|
||||
| MCP 工具 | `MCP()` / `MustMCP()` | `ListTools` / `CallTool`(见 §8) |
|
||||
| 图形验证码 | `Captcha()` / `MustCaptcha()` | `Generate` / `Verify` / 登录防暴力(见 §8) |
|
||||
| HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` |
|
||||
| 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` |
|
||||
| 迁移 | `Migrator(dir)` | Up / Down / Status |
|
||||
@@ -184,6 +186,61 @@ tools.Now()
|
||||
tools.MD5("text")
|
||||
```
|
||||
|
||||
### MCP 工具(Model Context Protocol)Client
|
||||
|
||||
本库只做 **MCP Client**:按配置连接外部 MCP server(容器/服务),发现工具、调用工具;
|
||||
**不含路由/抽参/编排逻辑**——用哪个工具、怎么填参数、怎么组合答复,属于业务侧的
|
||||
Prompt/Agent 编排,需要业务项目自行实现(可参考已有 RAG 项目里的 ToolOrchestrator 写法)。
|
||||
|
||||
```go
|
||||
m := app.MustMCP() // config.mcp 未配置或 enabled=false 时 m.Enabled() 为 false
|
||||
|
||||
tools, _ := m.ListTools(ctx) // 聚合全部已启用 server 的工具;FQName = "{toolNamePrefix}__{原始工具名}"
|
||||
res, _ := m.CallTool(ctx, "word__create_document", map[string]any{"title": "周报"})
|
||||
|
||||
// 可选:把 HTTP 层的 request id 传入,调用日志会附带该字段(不传不影响功能)
|
||||
ctx = mcp.WithRequestID(ctx, requestID)
|
||||
```
|
||||
|
||||
`config.json` 中 `mcp.servers[]` 一条 = 一个独立 MCP server(自带多个工具),新增能力 =
|
||||
加一条配置,不改代码;`allowedTools` 留空表示开放该 server 全部工具。完整字段见
|
||||
[`config/example.json`](./config/example.json) 的 `mcp` 段。
|
||||
|
||||
### 图形验证码(Captcha)
|
||||
|
||||
可选模块:`config.captcha.enabled=false`(或未配置)时 `Captcha().Enabled()` 为 false,**不影响 middleware**;业务 handler 按需调用即可。
|
||||
|
||||
```go
|
||||
cap := app.MustCaptcha() // enabled=true 时需配置 redis
|
||||
|
||||
// 1. 生成(注册/改手机号等发短信前)
|
||||
result, _ := cap.Generate(ctx, captcha.SceneRegister)
|
||||
// 返回 result.ID + result.ImageBase64 给前端展示
|
||||
|
||||
// 2. 校验(一次性消费)
|
||||
if err := cap.Verify(ctx, captcha.VerifyRequest{
|
||||
Scene: captcha.SceneRegister, ID: captchaID, Answer: userInput,
|
||||
}); err != nil {
|
||||
h.Error("captcha.invalid")
|
||||
return
|
||||
}
|
||||
// 验证通过后再 app.MustSMS().SendSMS(...)
|
||||
|
||||
// 3. 登录防暴力(失败 N 次后要求验证码,M 次后锁定)
|
||||
key := username // 或 username+IP,由业务决定
|
||||
if need, _ := cap.LoginNeedCaptcha(ctx, key); need {
|
||||
// 要求前端传 captchaId + captchaCode,并 Verify
|
||||
}
|
||||
status, err := cap.RecordLoginFailure(ctx, key)
|
||||
if status.Locked { h.Error("captcha.login_locked"); return }
|
||||
// 登录成功后
|
||||
_ = cap.ClearLoginFailures(ctx, key)
|
||||
```
|
||||
|
||||
场景常量:`SceneRegister` / `SceneChangePhone` / `SceneLogin` / `SceneSendSMS`。
|
||||
`scenes.register.mode=always` 表示该场景始终要求图形验证码;登录场景由 `login.showAfterFailures` 控制「失败后才要求」。
|
||||
完整配置见 [`config/example.json`](./config/example.json) 的 `captcha` 段。
|
||||
|
||||
---
|
||||
|
||||
## 9. 最小 config.json
|
||||
@@ -222,6 +279,7 @@ tools.MD5("text")
|
||||
- 自行 `gorm.Open` / `redis.NewClient`
|
||||
- Factory 透传(`LogInfo`、`Success`、`Now` 等)
|
||||
- 请求路径滥用 `MustXxx`
|
||||
- 把 MCP 工具路由/抽参/Prompt 编排逻辑塞进本库(应在业务侧基于 `mcp.Manager` 自行实现)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ git.toowon.com/jimmy/go-common
|
||||
|
||||
```bash
|
||||
go env -w GOPRIVATE=git.toowon.com
|
||||
go get git.toowon.com/jimmy/go-common@v1.2.0
|
||||
go get git.toowon.com/jimmy/go-common@v1.4.0
|
||||
```
|
||||
|
||||
## 设计
|
||||
@@ -33,6 +33,8 @@ go get git.toowon.com/jimmy/go-common@v1.2.0
|
||||
| `i18n` | 多语言消息 |
|
||||
| `migration` | SQL 迁移 |
|
||||
| `tools` | 时间、加密、金额等 |
|
||||
| `mcp` | MCP Client(多 server、`ListTools`/`CallTool`) |
|
||||
| `captcha` | 图形验证码、登录防暴力(可选,`enabled` 开关) |
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -18,16 +18,18 @@ git push origin v1.2.0
|
||||
## 消费方引用
|
||||
|
||||
```bash
|
||||
go get git.toowon.com/jimmy/go-common@v1.2.0 # 生产固定版本
|
||||
go get git.toowon.com/jimmy/go-common@v1.3.0 # 生产固定版本
|
||||
go get git.toowon.com/jimmy/go-common@latest # 开发
|
||||
```
|
||||
|
||||
## 当前版本
|
||||
|
||||
**v1.2.0**
|
||||
**v1.4.0**
|
||||
|
||||
## 版本历史
|
||||
|
||||
- **v1.4.0** — 新增 `captcha` 模块:图形验证码(`Generate`/`Verify`、Redis 存储、场景配置)与登录防暴力(失败计数、触发验证码、临时锁定);`config.CaptchaConfig`;`factory.Captcha()`/`MustCaptcha()`/`WithCaptcha`/`ModuleCaptcha`;**不进默认 middleware**,第三方按需 `enabled` 开关
|
||||
- **v1.3.0** — 新增 `mcp` 模块:MCP(Model Context Protocol)Client(`mcp.Manager`:多 server、命名空间前缀、白名单、`ListTools`/`CallTool`);`config.MCPConfig`;`factory.MCP()`/`MustMCP()`/`WithMCP`;不含路由/抽参/编排(业务侧自行实现)
|
||||
- **v1.2.0** — DX:`MustInit` / `Warmup` / `MustXxx` / `Close` / `NewHandler`;Option 注入;Excel 每次新建;`templates/server`;`ErrorData`;文档精简
|
||||
- **v1.1.0** — 同上 DX 能力首次合入(见上)
|
||||
- **v1.0.0** — 初始:Factory lazy getter、http.Handler 统一出参、logger / email / sms / storage / middleware / migration / tools / i18n / excel
|
||||
|
||||
307
captcha/captcha.go
Normal file
307
captcha/captcha.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/config"
|
||||
"github.com/mojocn/base64Captcha"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Scene 验证码业务场景。
|
||||
type Scene string
|
||||
|
||||
const (
|
||||
SceneRegister Scene = "register"
|
||||
SceneChangePhone Scene = "change_phone"
|
||||
SceneLogin Scene = "login"
|
||||
SceneSendSMS Scene = "send_sms"
|
||||
)
|
||||
|
||||
// GenerateResult 生成图形验证码的返回。
|
||||
type GenerateResult struct {
|
||||
ID string `json:"id"`
|
||||
ImageBase64 string `json:"imageBase64"`
|
||||
}
|
||||
|
||||
// VerifyRequest 校验图形验证码的请求。
|
||||
type VerifyRequest struct {
|
||||
Scene Scene
|
||||
ID string
|
||||
Answer string
|
||||
}
|
||||
|
||||
// LoginGuardStatus 登录防暴力状态。
|
||||
type LoginGuardStatus struct {
|
||||
FailCount int `json:"failCount"`
|
||||
NeedCaptcha bool `json:"needCaptcha"`
|
||||
Locked bool `json:"locked"`
|
||||
RemainingSec int `json:"remainingSec"`
|
||||
}
|
||||
|
||||
// Manager 图形验证码子系统(`app.Captcha()` 取得)。
|
||||
//
|
||||
// 职责:
|
||||
// - 生成/校验图形验证码(Redis 存储,一次性消费);
|
||||
// - 登录失败计数、触发验证码、临时锁定;
|
||||
// - Enabled() 反映 config.captcha.enabled 且 Redis 可用。
|
||||
//
|
||||
// 不含路由与业务编排:消费方在 handler 中按需调用 Generate / Verify / RecordLoginFailure。
|
||||
type Manager interface {
|
||||
Enabled() bool
|
||||
Generate(ctx context.Context, scene Scene) (*GenerateResult, error)
|
||||
Verify(ctx context.Context, req VerifyRequest) error
|
||||
SceneRequiresCaptcha(scene Scene) bool
|
||||
LoginNeedCaptcha(ctx context.Context, key string) (bool, error)
|
||||
IsLoginLocked(ctx context.Context, key string) (bool, int, error)
|
||||
RecordLoginFailure(ctx context.Context, key string) (LoginGuardStatus, error)
|
||||
ClearLoginFailures(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
type manager struct {
|
||||
enabled bool
|
||||
cfg *config.CaptchaConfig
|
||||
rdb *redis.Client
|
||||
captcha *base64Captcha.Captcha
|
||||
}
|
||||
|
||||
// NewManager 根据配置与 Redis 构建图形验证码子系统。
|
||||
// cfg 为 nil 或 enabled=false 时返回 Enabled()=false 的安全实例,其余方法返回 ErrDisabled。
|
||||
// enabled=true 但 rdb=nil 时同样视为不可用(Enabled()=false)。
|
||||
func NewManager(rdb *redis.Client, cfg *config.CaptchaConfig) Manager {
|
||||
m := &manager{cfg: cfg}
|
||||
if cfg == nil || !cfg.Enabled || rdb == nil {
|
||||
return m
|
||||
}
|
||||
|
||||
normalizeCaptchaConfig(cfg)
|
||||
|
||||
ttl := time.Duration(cfg.TTLSec) * time.Second
|
||||
store := newRedisStore(rdb, ttl)
|
||||
driver := base64Captcha.NewDriverDigit(cfg.Height, cfg.Width, cfg.Length, 0.7, 80)
|
||||
m.enabled = true
|
||||
m.rdb = rdb
|
||||
m.captcha = base64Captcha.NewCaptcha(driver, store)
|
||||
return m
|
||||
}
|
||||
|
||||
func normalizeCaptchaConfig(cfg *config.CaptchaConfig) {
|
||||
if cfg.Length == 0 {
|
||||
cfg.Length = 4
|
||||
}
|
||||
if cfg.Width == 0 {
|
||||
cfg.Width = 120
|
||||
}
|
||||
if cfg.Height == 0 {
|
||||
cfg.Height = 40
|
||||
}
|
||||
if cfg.TTLSec == 0 {
|
||||
cfg.TTLSec = 300
|
||||
}
|
||||
if cfg.Login == nil {
|
||||
cfg.Login = &config.LoginCaptchaConfig{}
|
||||
}
|
||||
if cfg.Login.ShowAfterFailures == 0 {
|
||||
cfg.Login.ShowAfterFailures = 3
|
||||
}
|
||||
if cfg.Login.LockAfterFailures == 0 {
|
||||
cfg.Login.LockAfterFailures = 10
|
||||
}
|
||||
if cfg.Login.LockDurationMin == 0 {
|
||||
cfg.Login.LockDurationMin = 30
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) Enabled() bool {
|
||||
return m != nil && m.enabled
|
||||
}
|
||||
|
||||
func (m *manager) Generate(ctx context.Context, scene Scene) (*GenerateResult, error) {
|
||||
if err := m.requireEnabled(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = ctx
|
||||
|
||||
id, b64s, _, err := m.captcha.Generate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("captcha generate: %w", err)
|
||||
}
|
||||
return &GenerateResult{
|
||||
ID: id,
|
||||
ImageBase64: b64s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *manager) Verify(ctx context.Context, req VerifyRequest) error {
|
||||
if err := m.requireEnabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = ctx
|
||||
|
||||
id := strings.TrimSpace(req.ID)
|
||||
answer := strings.TrimSpace(req.Answer)
|
||||
if id == "" || answer == "" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if !m.captcha.Verify(id, answer, true) {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) SceneRequiresCaptcha(scene Scene) bool {
|
||||
if !m.Enabled() {
|
||||
return false
|
||||
}
|
||||
if scene == SceneLogin {
|
||||
return false
|
||||
}
|
||||
mode := m.sceneMode(scene)
|
||||
return mode == "always"
|
||||
}
|
||||
|
||||
func (m *manager) LoginNeedCaptcha(ctx context.Context, key string) (bool, error) {
|
||||
if !m.Enabled() {
|
||||
return false, nil
|
||||
}
|
||||
loginCfg := m.loginConfig()
|
||||
if loginCfg.ShowAfterFailures <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
locked, _, err := m.IsLoginLocked(ctx, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if locked {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
count, err := m.getLoginFailCount(ctx, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count >= loginCfg.ShowAfterFailures, nil
|
||||
}
|
||||
|
||||
func (m *manager) IsLoginLocked(ctx context.Context, key string) (bool, int, error) {
|
||||
if !m.Enabled() {
|
||||
return false, 0, nil
|
||||
}
|
||||
ttl, err := m.rdb.TTL(ctx, loginLockKey(key)).Result()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return false, 0, nil
|
||||
}
|
||||
return true, int(ttl.Seconds()), nil
|
||||
}
|
||||
|
||||
func (m *manager) RecordLoginFailure(ctx context.Context, key string) (LoginGuardStatus, error) {
|
||||
status := LoginGuardStatus{}
|
||||
if !m.Enabled() {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
loginCfg := m.loginConfig()
|
||||
lockKey := loginLockKey(key)
|
||||
failKey := loginFailKey(key)
|
||||
|
||||
if ttl, err := m.rdb.TTL(ctx, lockKey).Result(); err != nil {
|
||||
return status, err
|
||||
} else if ttl > 0 {
|
||||
status.Locked = true
|
||||
status.NeedCaptcha = true
|
||||
status.RemainingSec = int(ttl.Seconds())
|
||||
return status, ErrLoginLocked
|
||||
}
|
||||
|
||||
count, err := m.rdb.Incr(ctx, failKey).Result()
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
if count == 1 {
|
||||
_ = m.rdb.Expire(ctx, failKey, time.Duration(loginCfg.LockDurationMin)*time.Minute).Err()
|
||||
}
|
||||
|
||||
status.FailCount = int(count)
|
||||
status.NeedCaptcha = loginCfg.ShowAfterFailures > 0 && int(count) >= loginCfg.ShowAfterFailures
|
||||
|
||||
if loginCfg.LockAfterFailures > 0 && int(count) >= loginCfg.LockAfterFailures {
|
||||
lockTTL := time.Duration(loginCfg.LockDurationMin) * time.Minute
|
||||
if err := m.rdb.Set(ctx, lockKey, "1", lockTTL).Err(); err != nil {
|
||||
return status, err
|
||||
}
|
||||
_ = m.rdb.Del(ctx, failKey).Err()
|
||||
status.Locked = true
|
||||
status.NeedCaptcha = true
|
||||
status.RemainingSec = int(lockTTL.Seconds())
|
||||
return status, ErrLoginLocked
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (m *manager) ClearLoginFailures(ctx context.Context, key string) error {
|
||||
if !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
return m.rdb.Del(ctx, loginFailKey(key), loginLockKey(key)).Err()
|
||||
}
|
||||
|
||||
func (m *manager) requireEnabled() error {
|
||||
if !m.Enabled() {
|
||||
return ErrDisabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) sceneMode(scene Scene) string {
|
||||
if m.cfg == nil || m.cfg.Scenes == nil {
|
||||
return defaultSceneMode(scene)
|
||||
}
|
||||
if sc, ok := m.cfg.Scenes[string(scene)]; ok && sc.Mode != "" {
|
||||
return sc.Mode
|
||||
}
|
||||
return defaultSceneMode(scene)
|
||||
}
|
||||
|
||||
func defaultSceneMode(scene Scene) string {
|
||||
switch scene {
|
||||
case SceneRegister, SceneChangePhone, SceneSendSMS:
|
||||
return "always"
|
||||
default:
|
||||
return "never"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) loginConfig() config.LoginCaptchaConfig {
|
||||
if m.cfg == nil || m.cfg.Login == nil {
|
||||
return config.LoginCaptchaConfig{
|
||||
ShowAfterFailures: 3,
|
||||
LockAfterFailures: 10,
|
||||
LockDurationMin: 30,
|
||||
}
|
||||
}
|
||||
return *m.cfg.Login
|
||||
}
|
||||
|
||||
func (m *manager) getLoginFailCount(ctx context.Context, key string) (int, error) {
|
||||
n, err := m.rdb.Get(ctx, loginFailKey(key)).Int()
|
||||
if err == redis.Nil {
|
||||
return 0, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func loginFailKey(key string) string {
|
||||
return "captcha:login:fail:" + key
|
||||
}
|
||||
|
||||
func loginLockKey(key string) string {
|
||||
return "captcha:login:lock:" + key
|
||||
}
|
||||
174
captcha/captcha_test.go
Normal file
174
captcha/captcha_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package captcha_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/captcha"
|
||||
"git.toowon.com/jimmy/go-common/config"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestDisabledManager(t *testing.T) {
|
||||
m := captcha.NewManager(nil, nil)
|
||||
if m.Enabled() {
|
||||
t.Fatal("expected disabled")
|
||||
}
|
||||
_, err := m.Generate(context.Background(), captcha.SceneRegister)
|
||||
if err != captcha.ErrDisabled {
|
||||
t.Fatalf("Generate err = %v, want ErrDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAndVerify(t *testing.T) {
|
||||
s, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
|
||||
cfg := &config.CaptchaConfig{
|
||||
Enabled: true,
|
||||
Length: 4,
|
||||
Width: 120,
|
||||
Height: 40,
|
||||
TTLSec: 300,
|
||||
Login: &config.LoginCaptchaConfig{},
|
||||
}
|
||||
m := captcha.NewManager(rdb, cfg)
|
||||
if !m.Enabled() {
|
||||
t.Fatal("expected enabled")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := m.Generate(ctx, captcha.SceneRegister)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.ID == "" || result.ImageBase64 == "" {
|
||||
t.Fatal("expected id and image")
|
||||
}
|
||||
|
||||
// 从 redis 读取答案用于测试
|
||||
answer, err := rdb.Get(ctx, "captcha:code:"+result.ID).Result()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Verify(ctx, captcha.VerifyRequest{
|
||||
Scene: captcha.SceneRegister,
|
||||
ID: result.ID,
|
||||
Answer: answer,
|
||||
}); err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if err := m.Verify(ctx, captcha.VerifyRequest{
|
||||
Scene: captcha.SceneRegister,
|
||||
ID: result.ID,
|
||||
Answer: answer,
|
||||
}); err != captcha.ErrInvalid {
|
||||
t.Fatalf("second Verify err = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginGuard(t *testing.T) {
|
||||
s, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
|
||||
cfg := &config.CaptchaConfig{
|
||||
Enabled: true,
|
||||
Length: 4,
|
||||
Width: 120,
|
||||
Height: 40,
|
||||
TTLSec: 300,
|
||||
Login: &config.LoginCaptchaConfig{
|
||||
ShowAfterFailures: 2,
|
||||
LockAfterFailures: 4,
|
||||
LockDurationMin: 1,
|
||||
},
|
||||
}
|
||||
m := captcha.NewManager(rdb, cfg)
|
||||
ctx := context.Background()
|
||||
key := "alice"
|
||||
|
||||
status, err := m.RecordLoginFailure(ctx, key)
|
||||
if err != nil || status.FailCount != 1 || status.NeedCaptcha {
|
||||
t.Fatalf("first failure: status=%+v err=%v", status, err)
|
||||
}
|
||||
|
||||
status, err = m.RecordLoginFailure(ctx, key)
|
||||
if err != nil || status.FailCount != 2 || !status.NeedCaptcha {
|
||||
t.Fatalf("second failure: status=%+v err=%v", status, err)
|
||||
}
|
||||
|
||||
need, err := m.LoginNeedCaptcha(ctx, key)
|
||||
if err != nil || !need {
|
||||
t.Fatalf("LoginNeedCaptcha = %v, err = %v", need, err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
_, _ = m.RecordLoginFailure(ctx, key)
|
||||
}
|
||||
status, err = m.RecordLoginFailure(ctx, key)
|
||||
if err != captcha.ErrLoginLocked || !status.Locked {
|
||||
t.Fatalf("lock: status=%+v err=%v", status, err)
|
||||
}
|
||||
|
||||
locked, remaining, err := m.IsLoginLocked(ctx, key)
|
||||
if err != nil || !locked || remaining <= 0 {
|
||||
t.Fatalf("IsLoginLocked = %v, %d, err = %v", locked, remaining, err)
|
||||
}
|
||||
|
||||
if err := m.ClearLoginFailures(ctx, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locked, _, _ = m.IsLoginLocked(ctx, key)
|
||||
if locked {
|
||||
t.Fatal("expected unlocked after clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneRequiresCaptcha(t *testing.T) {
|
||||
cfg := &config.CaptchaConfig{
|
||||
Enabled: true,
|
||||
Scenes: map[string]config.CaptchaSceneConfig{
|
||||
"register": {Mode: "always"},
|
||||
"login": {Mode: "never"},
|
||||
},
|
||||
}
|
||||
m := captcha.NewManager(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}), cfg)
|
||||
if !m.SceneRequiresCaptcha(captcha.SceneRegister) {
|
||||
t.Fatal("register should require captcha")
|
||||
}
|
||||
if m.SceneRequiresCaptcha(captcha.SceneLogin) {
|
||||
t.Fatal("login should not always require captcha")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStoreTTL(t *testing.T) {
|
||||
s, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
|
||||
cfg := &config.CaptchaConfig{Enabled: true, TTLSec: 1, Login: &config.LoginCaptchaConfig{}}
|
||||
m := captcha.NewManager(rdb, cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := m.Generate(ctx, captcha.SceneRegister)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.FastForward(2 * time.Second)
|
||||
if err := m.Verify(ctx, captcha.VerifyRequest{ID: result.ID, Answer: "x"}); err != captcha.ErrInvalid {
|
||||
t.Fatalf("expired verify err = %v", err)
|
||||
}
|
||||
}
|
||||
17
captcha/errors.go
Normal file
17
captcha/errors.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package captcha
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrDisabled 图形验证码子系统未启用。
|
||||
ErrDisabled = errors.New("captcha disabled")
|
||||
|
||||
// ErrInvalid 验证码错误或已失效。
|
||||
ErrInvalid = errors.New("captcha invalid")
|
||||
|
||||
// ErrRequired 当前场景要求验证码但未提供或校验失败。
|
||||
ErrRequired = errors.New("captcha required")
|
||||
|
||||
// ErrLoginLocked 登录失败次数过多,账号或 IP 已被临时锁定。
|
||||
ErrLoginLocked = errors.New("login locked")
|
||||
)
|
||||
42
captcha/redis_store.go
Normal file
42
captcha/redis_store.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const codeKeyPrefix = "captcha:code:"
|
||||
|
||||
// redisStore 实现 base64Captcha.Store,将验证码答案存入 Redis。
|
||||
type redisStore struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newRedisStore(client *redis.Client, ttl time.Duration) *redisStore {
|
||||
return &redisStore{client: client, ttl: ttl}
|
||||
}
|
||||
|
||||
func (s *redisStore) Set(id string, value string) error {
|
||||
return s.client.Set(context.Background(), codeKeyPrefix+id, value, s.ttl).Err()
|
||||
}
|
||||
|
||||
func (s *redisStore) Get(id string, clear bool) string {
|
||||
ctx := context.Background()
|
||||
key := codeKeyPrefix + id
|
||||
val, err := s.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if clear {
|
||||
_ = s.client.Del(ctx, key).Err()
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func (s *redisStore) Verify(id, answer string, clear bool) bool {
|
||||
val := s.Get(id, clear)
|
||||
return val != "" && val == answer
|
||||
}
|
||||
133
config/config.go
133
config/config.go
@@ -20,6 +20,8 @@ type Config struct {
|
||||
Logger *LoggerConfig `json:"logger"`
|
||||
I18n *I18nConfig `json:"i18n"`
|
||||
RateLimit *RateLimitConfig `json:"rateLimit"`
|
||||
MCP *MCPConfig `json:"mcp"`
|
||||
Captcha *CaptchaConfig `json:"captcha"`
|
||||
}
|
||||
|
||||
// I18nConfig 国际化配置
|
||||
@@ -307,6 +309,48 @@ func (c *LoggerConfig) IsAsync() bool {
|
||||
return *c.Async
|
||||
}
|
||||
|
||||
// CaptchaConfig 图形验证码配置(可选模块;enabled=false 或未配置时不启用)
|
||||
type CaptchaConfig struct {
|
||||
// Enabled 总开关
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Length 验证码字符数,默认 4
|
||||
Length int `json:"length"`
|
||||
|
||||
// Width 图片宽度(像素),默认 120
|
||||
Width int `json:"width"`
|
||||
|
||||
// Height 图片高度(像素),默认 40
|
||||
Height int `json:"height"`
|
||||
|
||||
// TTLSec 验证码有效期(秒),默认 300
|
||||
TTLSec int `json:"ttlSec"`
|
||||
|
||||
// Login 登录防暴力配置(失败 N 次后要求验证码,M 次后锁定)
|
||||
Login *LoginCaptchaConfig `json:"login"`
|
||||
|
||||
// Scenes 各场景是否强制图形验证码(mode: always | never;login 场景由 Login 配置控制)
|
||||
Scenes map[string]CaptchaSceneConfig `json:"scenes"`
|
||||
}
|
||||
|
||||
// LoginCaptchaConfig 登录防暴力配置
|
||||
type LoginCaptchaConfig struct {
|
||||
// ShowAfterFailures 失败多少次后开始要求图形验证码,默认 3
|
||||
ShowAfterFailures int `json:"showAfterFailures"`
|
||||
|
||||
// LockAfterFailures 失败多少次后临时锁定,默认 10
|
||||
LockAfterFailures int `json:"lockAfterFailures"`
|
||||
|
||||
// LockDurationMin 锁定时长(分钟),默认 30
|
||||
LockDurationMin int `json:"lockDurationMin"`
|
||||
}
|
||||
|
||||
// CaptchaSceneConfig 单场景验证码策略
|
||||
type CaptchaSceneConfig struct {
|
||||
// Mode always=该场景始终要求图形验证码;never=不要求(login 场景忽略此项)
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// RateLimitConfig 限流配置
|
||||
type RateLimitConfig struct {
|
||||
// Enable 是否启用限流
|
||||
@@ -325,6 +369,50 @@ type RateLimitConfig struct {
|
||||
ByUserID bool `json:"byUserID"`
|
||||
}
|
||||
|
||||
// MCPConfig MCP(Model Context Protocol)工具子系统配置。
|
||||
// 本库只做 MCP **Client**:连接外部 MCP server(容器/服务),发现并调用工具;
|
||||
// 不包含任何路由/抽参/编排逻辑(那属于业务侧的 Prompt/Agent 编排,由消费方自行实现)。
|
||||
type MCPConfig struct {
|
||||
// Enabled 总开关;false 或 Servers 为空时 mcp.Manager.Enabled() 返回 false
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// CallTimeoutSeconds 单次连接/列工具/调用工具的超时(秒),默认 60
|
||||
CallTimeoutSeconds int `json:"callTimeoutSeconds"`
|
||||
|
||||
// Servers MCP 服务列表;一条 = 一个独立 MCP server(自带多个工具)
|
||||
Servers []MCPServerConfig `json:"servers"`
|
||||
}
|
||||
|
||||
// MCPServerConfig 单个 MCP server(容器/服务)的连接配置。
|
||||
type MCPServerConfig struct {
|
||||
// Name 逻辑名(用于日志与路由),必填
|
||||
Name string `json:"name"`
|
||||
|
||||
// Transport 传输类型:streamable-http(默认)| sse
|
||||
Transport string `json:"transport"`
|
||||
|
||||
// URL server 的 MCP 端点地址
|
||||
URL string `json:"url"`
|
||||
|
||||
// Enabled 是否启用该 server;nil 默认 true
|
||||
Enabled *bool `json:"enabled"`
|
||||
|
||||
// AuthToken 可选:Bearer/Basic 凭证;仅 token 时自动补 "Bearer "
|
||||
AuthToken string `json:"authToken"`
|
||||
|
||||
// ToolNamePrefix 工具命名空间前缀,防多 server 工具重名;留空则用 Name
|
||||
// 工具全名 = "{ToolNamePrefix}__{原始工具名}"
|
||||
ToolNamePrefix string `json:"toolNamePrefix"`
|
||||
|
||||
// AllowedTools 工具白名单(原始工具名,不含前缀);留空表示开放该 server 全部工具
|
||||
AllowedTools []string `json:"allowedTools"`
|
||||
}
|
||||
|
||||
// IsEnabled 该 server 是否启用(nil 默认 true)
|
||||
func (s *MCPServerConfig) IsEnabled() bool {
|
||||
return s == nil || s.Enabled == nil || *s.Enabled
|
||||
}
|
||||
|
||||
// LoadFromFile 从文件加载配置
|
||||
// filePath: 配置文件路径(支持绝对路径和相对路径)
|
||||
func LoadFromFile(filePath string) (*Config, error) {
|
||||
@@ -492,6 +580,41 @@ func (c *Config) setDefaults() {
|
||||
c.RateLimit.ByIP = true // 默认按IP限流
|
||||
}
|
||||
}
|
||||
|
||||
// MCP 默认值
|
||||
if c.MCP != nil {
|
||||
if c.MCP.CallTimeoutSeconds <= 0 {
|
||||
c.MCP.CallTimeoutSeconds = 60
|
||||
}
|
||||
}
|
||||
|
||||
// Captcha 默认值
|
||||
if c.Captcha != nil {
|
||||
if c.Captcha.Length == 0 {
|
||||
c.Captcha.Length = 4
|
||||
}
|
||||
if c.Captcha.Width == 0 {
|
||||
c.Captcha.Width = 120
|
||||
}
|
||||
if c.Captcha.Height == 0 {
|
||||
c.Captcha.Height = 40
|
||||
}
|
||||
if c.Captcha.TTLSec == 0 {
|
||||
c.Captcha.TTLSec = 300
|
||||
}
|
||||
if c.Captcha.Login == nil {
|
||||
c.Captcha.Login = &LoginCaptchaConfig{}
|
||||
}
|
||||
if c.Captcha.Login.ShowAfterFailures == 0 {
|
||||
c.Captcha.Login.ShowAfterFailures = 3
|
||||
}
|
||||
if c.Captcha.Login.LockAfterFailures == 0 {
|
||||
c.Captcha.Login.LockAfterFailures = 10
|
||||
}
|
||||
if c.Captcha.Login.LockDurationMin == 0 {
|
||||
c.Captcha.Login.LockDurationMin = 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDatabase 获取数据库配置
|
||||
@@ -546,6 +669,16 @@ func (c *Config) GetI18n() *I18nConfig {
|
||||
return c.I18n
|
||||
}
|
||||
|
||||
// GetMCP 获取 MCP 配置
|
||||
func (c *Config) GetMCP() *MCPConfig {
|
||||
return c.MCP
|
||||
}
|
||||
|
||||
// GetCaptcha 获取图形验证码配置
|
||||
func (c *Config) GetCaptcha() *CaptchaConfig {
|
||||
return c.Captcha
|
||||
}
|
||||
|
||||
// GetDatabaseDSN 获取数据库连接字符串
|
||||
func (c *Config) GetDatabaseDSN() (string, error) {
|
||||
if c.Database == nil {
|
||||
|
||||
@@ -89,6 +89,47 @@
|
||||
"period": 60,
|
||||
"byIP": true,
|
||||
"byUserID": false
|
||||
},
|
||||
"captcha": {
|
||||
"enabled": false,
|
||||
"length": 4,
|
||||
"width": 120,
|
||||
"height": 40,
|
||||
"ttlSec": 300,
|
||||
"login": {
|
||||
"showAfterFailures": 3,
|
||||
"lockAfterFailures": 10,
|
||||
"lockDurationMin": 30
|
||||
},
|
||||
"scenes": {
|
||||
"register": { "mode": "always" },
|
||||
"change_phone": { "mode": "always" },
|
||||
"send_sms": { "mode": "always" }
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": true,
|
||||
"callTimeoutSeconds": 60,
|
||||
"servers": [
|
||||
{
|
||||
"name": "fetch-web",
|
||||
"transport": "streamable-http",
|
||||
"url": "http://mcp-gateway:1111/mcp/fetch-web",
|
||||
"enabled": true,
|
||||
"authToken": "dev-local-token",
|
||||
"toolNamePrefix": "fetch-web",
|
||||
"allowedTools": ["fetch_web"]
|
||||
},
|
||||
{
|
||||
"name": "word",
|
||||
"transport": "sse",
|
||||
"url": "http://mcp-gateway:1111/mcp/word",
|
||||
"enabled": false,
|
||||
"authToken": "dev-local-token",
|
||||
"toolNamePrefix": "word",
|
||||
"allowedTools": ["create_document", "add_heading", "add_paragraph"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
62
examples/captcha_example.go
Normal file
62
examples/captcha_example.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build example
|
||||
// +build example
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/captcha"
|
||||
"git.toowon.com/jimmy/go-common/config"
|
||||
"git.toowon.com/jimmy/go-common/factory"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.LoadFromFile("./config/example.json")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
app := factory.New(cfg)
|
||||
cap, err := app.Captcha()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !cap.Enabled() {
|
||||
fmt.Println("captcha disabled — set captcha.enabled=true and configure redis to enable")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 注册场景:生成图形验证码
|
||||
result, err := cap.Generate(ctx, captcha.SceneRegister)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("captcha id: %s\n", result.ID)
|
||||
fmt.Printf("captcha image length: %d (base64)\n", len(result.ImageBase64))
|
||||
|
||||
// 2. 发短信前校验(业务侧在 handler 中调用;此处演示 API)
|
||||
// 实际使用时 answer 来自用户输入
|
||||
_ = cap.Verify(ctx, captcha.VerifyRequest{
|
||||
Scene: captcha.SceneRegister,
|
||||
ID: result.ID,
|
||||
Answer: "user-input",
|
||||
})
|
||||
|
||||
// 3. 登录防暴力
|
||||
key := "user@example.com"
|
||||
status, _ := cap.RecordLoginFailure(ctx, key)
|
||||
fmt.Printf("login failures: %d, needCaptcha: %v, locked: %v\n",
|
||||
status.FailCount, status.NeedCaptcha, status.Locked)
|
||||
|
||||
need, _ := cap.LoginNeedCaptcha(ctx, key)
|
||||
fmt.Printf("login need captcha: %v\n", need)
|
||||
|
||||
// 登录成功后清除
|
||||
_ = cap.ClearLoginFailures(ctx, key)
|
||||
}
|
||||
61
examples/mcp_example.go
Normal file
61
examples/mcp_example.go
Normal file
@@ -0,0 +1,61 @@
|
||||
//go:build example
|
||||
// +build example
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/config"
|
||||
"git.toowon.com/jimmy/go-common/factory"
|
||||
)
|
||||
|
||||
// 本示例演示 MCP(Model Context Protocol)Client 的用法:
|
||||
// - Factory 只负责按配置装配 mcp.Manager(多 server + 命名空间 + 白名单);
|
||||
// - 业务侧拿到 Manager 后自行 ListTools / CallTool;
|
||||
// - 路由到哪个工具、怎么抽参、怎么组合答复,属于业务编排,不在本库范围
|
||||
// (可参考各业务项目里的 ToolOrchestrator/PromptOrchestrator 写法)。
|
||||
func main() {
|
||||
cfg, err := config.LoadFromFile("./config/example.json")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
app := factory.New(cfg)
|
||||
defer app.Close()
|
||||
|
||||
m, err := app.MCP()
|
||||
if err != nil {
|
||||
log.Fatal("mcp not configured:", err)
|
||||
}
|
||||
if !m.Enabled() {
|
||||
log.Fatal("mcp subsystem disabled (check config.mcp.enabled / servers)")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// ListTools 聚合全部已启用 server 的工具,工具名带命名空间前缀(防止多 server 重名)。
|
||||
tools, err := m.ListTools(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, t := range tools {
|
||||
fmt.Printf("tool: %s (server=%s) - %s\n", t.FQName, t.ServerName, t.Description)
|
||||
}
|
||||
|
||||
if len(tools) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// CallTool 按「命名空间全名」路由到对应 server;
|
||||
// 可选:用 mcp.WithRequestID 把 HTTP 层的 request id 传下去,方便日志串联。
|
||||
res, err := m.CallTool(ctx, tools[0].FQName, map[string]any{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("call result: is_error=%v text=%s\n", res.IsError, res.Text)
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/captcha"
|
||||
"git.toowon.com/jimmy/go-common/config"
|
||||
"git.toowon.com/jimmy/go-common/email"
|
||||
"git.toowon.com/jimmy/go-common/excel"
|
||||
commonhttp "git.toowon.com/jimmy/go-common/http"
|
||||
"git.toowon.com/jimmy/go-common/i18n"
|
||||
"git.toowon.com/jimmy/go-common/logger"
|
||||
"git.toowon.com/jimmy/go-common/mcp"
|
||||
"git.toowon.com/jimmy/go-common/middleware"
|
||||
"git.toowon.com/jimmy/go-common/migration"
|
||||
"git.toowon.com/jimmy/go-common/sms"
|
||||
@@ -40,6 +42,8 @@ type Factory struct {
|
||||
redis *redis.Client
|
||||
i18n *i18n.I18n
|
||||
chain *middleware.Chain
|
||||
mcp mcp.Manager
|
||||
captcha captcha.Manager
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
@@ -412,6 +416,64 @@ func (f *Factory) getI18nUnlocked() (*i18n.I18n, error) {
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (f *Factory) getMCP() (mcp.Manager, error) {
|
||||
if f.mcp != nil {
|
||||
return f.mcp, nil
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.mcp != nil {
|
||||
return f.mcp, nil
|
||||
}
|
||||
if f.cfg == nil || f.cfg.MCP == nil {
|
||||
return nil, fmt.Errorf("mcp config is nil")
|
||||
}
|
||||
l, _ := f.getLoggerUnlocked()
|
||||
f.mcp = mcp.NewManager(l, f.cfg.MCP)
|
||||
return f.mcp, nil
|
||||
}
|
||||
|
||||
// MCP 获取 MCP 工具子系统对象(ListTools / CallTool)。
|
||||
// 本库只做 MCP Client(发现+调用工具);路由/抽参/编排逻辑由业务侧基于此对象实现。
|
||||
func (f *Factory) MCP() (mcp.Manager, error) {
|
||||
return f.getMCP()
|
||||
}
|
||||
|
||||
func (f *Factory) getCaptcha() (captcha.Manager, error) {
|
||||
if f.captcha != nil {
|
||||
return f.captcha, nil
|
||||
}
|
||||
|
||||
var cfg *config.CaptchaConfig
|
||||
if f.cfg != nil {
|
||||
cfg = f.cfg.Captcha
|
||||
}
|
||||
|
||||
var rdb *redis.Client
|
||||
if cfg != nil && cfg.Enabled {
|
||||
var err error
|
||||
rdb, err = f.getRedis()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("captcha enabled but redis unavailable: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.captcha != nil {
|
||||
return f.captcha, nil
|
||||
}
|
||||
f.captcha = captcha.NewManager(rdb, cfg)
|
||||
return f.captcha, nil
|
||||
}
|
||||
|
||||
// Captcha 获取图形验证码子系统(Generate / Verify / 登录防暴力)。
|
||||
// config.captcha 未配置或 enabled=false 时返回 Enabled()=false 的安全实例。
|
||||
// enabled=true 时需配置 redis,否则返回 error。
|
||||
func (f *Factory) Captcha() (captcha.Manager, error) {
|
||||
return f.getCaptcha()
|
||||
}
|
||||
|
||||
// Migrator 创建迁移器并加载指定目录下的 SQL 文件
|
||||
func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) {
|
||||
db, err := f.getDatabase()
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/captcha"
|
||||
"git.toowon.com/jimmy/go-common/email"
|
||||
"git.toowon.com/jimmy/go-common/i18n"
|
||||
"git.toowon.com/jimmy/go-common/logger"
|
||||
"git.toowon.com/jimmy/go-common/mcp"
|
||||
"git.toowon.com/jimmy/go-common/sms"
|
||||
"git.toowon.com/jimmy/go-common/storage"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -24,6 +26,8 @@ const (
|
||||
ModuleEmail Module = "email"
|
||||
ModuleSMS Module = "sms"
|
||||
ModuleI18n Module = "i18n"
|
||||
ModuleMCP Module = "mcp"
|
||||
ModuleCaptcha Module = "captcha"
|
||||
)
|
||||
|
||||
// MustInit 从配置文件初始化全局 Factory;失败则 panic(适合 main 启动)
|
||||
@@ -56,6 +60,10 @@ func (f *Factory) Warmup(modules ...Module) error {
|
||||
_, err = f.SMS()
|
||||
case ModuleI18n:
|
||||
_, err = f.I18n()
|
||||
case ModuleMCP:
|
||||
_, err = f.MCP()
|
||||
case ModuleCaptcha:
|
||||
_, err = f.Captcha()
|
||||
default:
|
||||
return fmt.Errorf("unknown module: %s", m)
|
||||
}
|
||||
@@ -104,6 +112,12 @@ func (f *Factory) Close() error {
|
||||
}
|
||||
f.db = nil
|
||||
}
|
||||
if f.mcp != nil {
|
||||
if err := f.mcp.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("mcp: %w", err))
|
||||
}
|
||||
f.mcp = nil
|
||||
}
|
||||
if f.logger != nil {
|
||||
if err := f.logger.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("logger: %w", err))
|
||||
@@ -179,3 +193,21 @@ func (f *Factory) MustI18n() *i18n.I18n {
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// MustMCP 获取 MCP 工具子系统对象;失败则 panic(适合启动期注入)
|
||||
func (f *Factory) MustMCP() mcp.Manager {
|
||||
m, err := f.MCP()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// MustCaptcha 获取图形验证码子系统对象;失败则 panic(适合启动期注入)
|
||||
func (f *Factory) MustCaptcha() captcha.Manager {
|
||||
c, err := f.Captcha()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"git.toowon.com/jimmy/go-common/captcha"
|
||||
"git.toowon.com/jimmy/go-common/email"
|
||||
"git.toowon.com/jimmy/go-common/i18n"
|
||||
"git.toowon.com/jimmy/go-common/logger"
|
||||
"git.toowon.com/jimmy/go-common/mcp"
|
||||
"git.toowon.com/jimmy/go-common/middleware"
|
||||
"git.toowon.com/jimmy/go-common/sms"
|
||||
"git.toowon.com/jimmy/go-common/storage"
|
||||
@@ -66,3 +68,17 @@ func WithMiddlewareChain(c *middleware.Chain) Option {
|
||||
f.chain = c
|
||||
}
|
||||
}
|
||||
|
||||
// WithMCP 注入自定义 MCP 管理器(如测试用的 fake 实现)
|
||||
func WithMCP(m mcp.Manager) Option {
|
||||
return func(f *Factory) {
|
||||
f.mcp = m
|
||||
}
|
||||
}
|
||||
|
||||
// WithCaptcha 注入自定义图形验证码实现(如测试用的 fake 实现)
|
||||
func WithCaptcha(c captcha.Manager) Option {
|
||||
return func(f *Factory) {
|
||||
f.captcha = c
|
||||
}
|
||||
}
|
||||
|
||||
17
go.mod
17
go.mod
@@ -1,12 +1,13 @@
|
||||
module git.toowon.com/jimmy/go-common
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.10
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/mojocn/base64Captcha v1.3.6
|
||||
github.com/redis/go-redis/v9 v9.17.1
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
@@ -22,6 +23,8 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -40,13 +43,19 @@ require (
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
golang.org/x/image v0.25.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
62
go.sum
62
go.sum
@@ -1,3 +1,5 @@
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -17,6 +19,14 @@ github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3I
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -50,6 +60,10 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/mojocn/base64Captcha v1.3.6 h1:gZEKu1nsKpttuIAQgWHO+4Mhhls8cAKyiV2Ew03H+Tw=
|
||||
github.com/mojocn/base64Captcha v1.3.6/go.mod h1:i5CtHvm+oMbj1UzEPXaA8IH/xHFZ3DGY3Wh3dBpZ28E=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -65,6 +79,10 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -80,18 +98,58 @@ github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstf
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
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/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
299
mcp/client.go
Normal file
299
mcp/client.go
Normal file
@@ -0,0 +1,299 @@
|
||||
// Package mcp 提供 MCP(Model Context Protocol)**Client** 能力:连接外部 MCP
|
||||
// server(容器/服务),发现工具、调用工具。
|
||||
//
|
||||
// 定位(见 .cursor/skills/go-common/SKILL.md):本包只做「连接 + 调用」基础设施,
|
||||
// 不包含任何路由 / 参数抽取 / 编排逻辑 —— 那属于业务侧的 Prompt/Agent 编排,
|
||||
// 由消费方基于 Manager 暴露的 ListTools/CallTool 自行实现(可参考各业务项目里的
|
||||
// ToolOrchestrator 写法)。
|
||||
//
|
||||
// 传输基于官方 SDK(github.com/modelcontextprotocol/go-sdk),支持
|
||||
// streamable-http / sse,绝不手写 JSON-RPC。
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/config"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
// clientName / clientVersion 在 initialize 握手时上报给 server,便于对端日志识别调用方。
|
||||
clientName = "go-common-mcp-client"
|
||||
clientVersion = "1.0.0"
|
||||
)
|
||||
|
||||
// ToolDescriptor 从 MCP server 拉取的工具元信息。
|
||||
type ToolDescriptor struct {
|
||||
Name string // 原始工具名(不含命名空间前缀)
|
||||
Description string // 工具说明
|
||||
InputSchema json.RawMessage // JSON Schema 原文,供上层抽参/校验
|
||||
}
|
||||
|
||||
// ToolCallResult 单次工具调用结果。
|
||||
type ToolCallResult struct {
|
||||
Text string // 合并后的文本内容(MCP text 内容块拼接)
|
||||
IsError bool // 工具自身报告的错误(非协议层错误)
|
||||
Raw json.RawMessage // 原始 CallToolResult JSON(含结构化内容/文件信息等)
|
||||
}
|
||||
|
||||
// Client 单个 MCP server 连接的最小接口;可替换实现(参考 storage.Storage 的做法),
|
||||
// 便于测试时注入 fake 实现。
|
||||
type Client interface {
|
||||
// Name 返回 server 逻辑名。
|
||||
Name() string
|
||||
// ListTools 列出该 server 暴露的全部工具(带 inputSchema)。
|
||||
ListTools(ctx context.Context) ([]ToolDescriptor, error)
|
||||
// CallTool 调用指定工具(name 为原始工具名,不含命名空间前缀)。
|
||||
CallTool(ctx context.Context, name string, args map[string]any) (ToolCallResult, error)
|
||||
// Close 关闭会话并释放资源。
|
||||
Close() error
|
||||
}
|
||||
|
||||
// sdkClient 是 Client 的默认实现:封装与单个 MCP server 的会话。
|
||||
// 惰性连接(首次调用才连)+ 失败后自动重连;调用超时不影响整个会话生命周期。
|
||||
type sdkClient struct {
|
||||
cfg config.MCPServerConfig
|
||||
callTimeout time.Duration
|
||||
sdk *mcpsdk.Client
|
||||
|
||||
mu sync.Mutex
|
||||
session *mcpsdk.ClientSession
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewClient 创建单个 MCP server 客户端(此时不建立连接,首次调用时惰性连接)。
|
||||
func NewClient(cfg config.MCPServerConfig, callTimeout time.Duration) Client {
|
||||
if callTimeout <= 0 {
|
||||
callTimeout = 60 * time.Second
|
||||
}
|
||||
return &sdkClient{
|
||||
cfg: cfg,
|
||||
callTimeout: callTimeout,
|
||||
sdk: mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, nil),
|
||||
}
|
||||
}
|
||||
|
||||
// Name 返回 server 逻辑名。
|
||||
func (c *sdkClient) Name() string { return c.cfg.Name }
|
||||
|
||||
// ListTools 列出 server 暴露的全部工具(带 inputSchema)。失败时重置会话以便下次重连。
|
||||
func (c *sdkClient) ListTools(ctx context.Context) ([]ToolDescriptor, error) {
|
||||
session, err := c.ensureSession(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opCtx, cancel := context.WithTimeout(ctx, c.callTimeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := session.ListTools(opCtx, nil)
|
||||
if err != nil {
|
||||
c.reset()
|
||||
return nil, fmt.Errorf("list tools from mcp server %q: %w", c.cfg.Name, err)
|
||||
}
|
||||
|
||||
out := make([]ToolDescriptor, 0, len(res.Tools))
|
||||
for _, t := range res.Tools {
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
schema, mErr := json.Marshal(t.InputSchema)
|
||||
if mErr != nil {
|
||||
schema = nil
|
||||
}
|
||||
out = append(out, ToolDescriptor{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: schema,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CallTool 调用指定工具(name 为原始工具名,不含命名空间前缀)。
|
||||
func (c *sdkClient) CallTool(ctx context.Context, name string, args map[string]any) (ToolCallResult, error) {
|
||||
session, err := c.ensureSession(ctx)
|
||||
if err != nil {
|
||||
return ToolCallResult{}, err
|
||||
}
|
||||
opCtx, cancel := context.WithTimeout(ctx, c.callTimeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := session.CallTool(opCtx, &mcpsdk.CallToolParams{Name: name, Arguments: args})
|
||||
if err != nil {
|
||||
c.reset()
|
||||
return ToolCallResult{}, fmt.Errorf("call tool %q on mcp server %q: %w", name, c.cfg.Name, err)
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(res)
|
||||
return ToolCallResult{
|
||||
Text: joinTextContent(res.Content),
|
||||
IsError: res.IsError,
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close 关闭会话并释放资源。
|
||||
func (c *sdkClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.closeLocked()
|
||||
}
|
||||
|
||||
// ensureSession 确保会话已连接(SDK 在 Connect 内自动完成 initialize 握手),返回可用会话。
|
||||
//
|
||||
// 注意:Connect 传入的 context 会被用于会话的整个生命周期(含后台监听),因此必须使用
|
||||
// 后台 context 而非调用超时 context;否则调用结束取消 context 会断开会话。这里通过
|
||||
// goroutine + 定时器为「连接握手」单独施加超时,超时则取消后台 context 放弃本次连接。
|
||||
func (c *sdkClient) ensureSession(ctx context.Context) (*mcpsdk.ClientSession, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.session != nil {
|
||||
return c.session, nil
|
||||
}
|
||||
|
||||
transport, err := c.newTransport()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseCtx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
type result struct {
|
||||
session *mcpsdk.ClientSession
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
s, e := c.sdk.Connect(baseCtx, transport, nil)
|
||||
ch <- result{session: s, err: e}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
return nil, fmt.Errorf("connect mcp server %q canceled: %w", c.cfg.Name, ctx.Err())
|
||||
case <-time.After(c.callTimeout):
|
||||
cancel()
|
||||
return nil, fmt.Errorf("connect mcp server %q timeout after %s", c.cfg.Name, c.callTimeout)
|
||||
case r := <-ch:
|
||||
if r.err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("connect mcp server %q: %w", c.cfg.Name, r.err)
|
||||
}
|
||||
c.session = r.session
|
||||
c.cancel = cancel
|
||||
return r.session, nil
|
||||
}
|
||||
}
|
||||
|
||||
// newTransport 按传输类型构造官方 SDK 传输;带鉴权时注入自定义 HTTP 客户端添加 Authorization 头。
|
||||
func (c *sdkClient) newTransport() (mcpsdk.Transport, error) {
|
||||
httpClient := c.authHTTPClient()
|
||||
switch normalizeTransport(c.cfg.Transport) {
|
||||
case "sse":
|
||||
return &mcpsdk.SSEClientTransport{Endpoint: c.cfg.URL, HTTPClient: httpClient}, nil
|
||||
case "streamable-http":
|
||||
// DisableStandaloneSSE:仅做请求-响应式调用,不维持服务端主动推送的持久 SSE 流,
|
||||
// 兼容性更好、超时语义更清晰。
|
||||
return &mcpsdk.StreamableClientTransport{
|
||||
Endpoint: c.cfg.URL,
|
||||
HTTPClient: httpClient,
|
||||
DisableStandaloneSSE: true,
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported mcp transport %q for server %q", c.cfg.Transport, c.cfg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// authHTTPClient 在配置了凭证时返回注入 Authorization 头的 HTTP 客户端;否则返回 nil(SDK 用默认客户端)。
|
||||
func (c *sdkClient) authHTTPClient() *http.Client {
|
||||
value := authHeaderValue(c.cfg.AuthToken)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &http.Client{Transport: &authRoundTripper{base: http.DefaultTransport, value: value}}
|
||||
}
|
||||
|
||||
// reset 重置会话,使下次调用重新连接(处理连接中断/对端重启)。
|
||||
func (c *sdkClient) reset() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_ = c.closeLocked()
|
||||
}
|
||||
|
||||
// closeLocked 在持有 mu 的前提下关闭会话。
|
||||
func (c *sdkClient) closeLocked() error {
|
||||
var err error
|
||||
if c.session != nil {
|
||||
err = c.session.Close()
|
||||
c.session = nil
|
||||
}
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
c.cancel = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// authRoundTripper 为每个出站请求注入固定的 Authorization 头。
|
||||
type authRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
value string
|
||||
}
|
||||
|
||||
func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
r := req.Clone(req.Context())
|
||||
r.Header.Set("Authorization", a.value)
|
||||
base := a.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
return base.RoundTrip(r)
|
||||
}
|
||||
|
||||
// joinTextContent 拼接 MCP 返回内容中的所有 text 块(忽略图片/音频等其他类型)。
|
||||
func joinTextContent(contents []mcpsdk.Content) string {
|
||||
var b strings.Builder
|
||||
for _, ct := range contents {
|
||||
if tc, ok := ct.(*mcpsdk.TextContent); ok {
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeTransport 归一化传输类型,空值/http 视为 streamable-http。
|
||||
func normalizeTransport(t string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(t)) {
|
||||
case "", "http", "streamable-http", "streamablehttp":
|
||||
return "streamable-http"
|
||||
case "sse":
|
||||
return "sse"
|
||||
default:
|
||||
return strings.TrimSpace(strings.ToLower(t))
|
||||
}
|
||||
}
|
||||
|
||||
// authHeaderValue 归一化鉴权头:已带 Bearer/Basic 前缀则原样使用,否则补 "Bearer "。
|
||||
func authHeaderValue(token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(token)
|
||||
if strings.HasPrefix(lower, "bearer ") || strings.HasPrefix(lower, "basic ") {
|
||||
return token
|
||||
}
|
||||
return "Bearer " + token
|
||||
}
|
||||
364
mcp/manager.go
Normal file
364
mcp/manager.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/config"
|
||||
"git.toowon.com/jimmy/go-common/logger"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// toolNameSeparator 命名空间分隔符:FQName = <prefix>__<rawName>,防多 server 工具重名。
|
||||
const toolNameSeparator = "__"
|
||||
|
||||
// toolCallLogMaxRunes 工具返回写入日志时的最大字符数,避免大返回值把日志灌爆。
|
||||
const toolCallLogMaxRunes = 2048
|
||||
|
||||
// Tool 暴露给上层(业务编排/接口)的工具元信息,带命名空间全名与所属 server。
|
||||
type Tool struct {
|
||||
FQName string `json:"fq_name"` // 命名空间全名,如 word__create_document
|
||||
Name string `json:"name"` // 原始工具名(不含前缀)
|
||||
ServerName string `json:"server_name"` // 所属 MCP server 逻辑名
|
||||
Description string `json:"description,omitempty"` // 工具说明
|
||||
InputSchema json.RawMessage `json:"input_schema,omitempty"` // JSON Schema 原文,供上层抽参/校验
|
||||
}
|
||||
|
||||
// ToolResult 工具调用结果。
|
||||
type ToolResult struct {
|
||||
Text string `json:"text"` // 合并后的文本内容
|
||||
IsError bool `json:"is_error"` // 工具自身报告的错误
|
||||
Raw json.RawMessage `json:"raw,omitempty"` // 原始结果 JSON(含文件名/路径等结构化信息)
|
||||
}
|
||||
|
||||
// Manager 是 MCP 工具子系统的标准模块对象(`app.MCP()` 取得)。
|
||||
//
|
||||
// 职责:
|
||||
// - 按配置管理多个 MCP server 客户端;
|
||||
// - 工具加命名空间前缀防冲突;
|
||||
// - CallTool 路由到对应 server;
|
||||
// - allowedTools 白名单收敛;
|
||||
// - 对外暴露工具的 InputSchema(供业务侧抽参)。
|
||||
//
|
||||
// 本接口是「基础底座」:仅负责工具的发现与调用,不包含路由/抽参/编排逻辑
|
||||
// (那属于业务侧的 Prompt/Agent 编排,由消费方基于本接口自行实现)。
|
||||
type Manager interface {
|
||||
// Enabled 子系统是否启用(关闭或无可用 server 时为 false)。
|
||||
Enabled() bool
|
||||
// ListTools 聚合所有 server 的可用工具(已按白名单过滤、带命名空间前缀)。
|
||||
ListTools(ctx context.Context) ([]Tool, error)
|
||||
// CallTool 按命名空间全名路由并调用工具。
|
||||
CallTool(ctx context.Context, fqName string, args map[string]any) (ToolResult, error)
|
||||
// Close 关闭全部底层 server 连接。
|
||||
Close() error
|
||||
}
|
||||
|
||||
// serverEntry 单个 server 的运行态:底层客户端 + 命名空间前缀 + 白名单。
|
||||
type serverEntry struct {
|
||||
name string
|
||||
prefix string
|
||||
allowed map[string]bool // 空表示放开全部工具
|
||||
client Client
|
||||
}
|
||||
|
||||
// isAllowed 判断原始工具名是否在白名单内(白名单为空=全部放开)。
|
||||
func (e *serverEntry) isAllowed(rawName string) bool {
|
||||
if len(e.allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
return e.allowed[rawName]
|
||||
}
|
||||
|
||||
// route 命名空间全名到「server + 原始工具名」的路由。
|
||||
type route struct {
|
||||
server *serverEntry
|
||||
rawName string
|
||||
}
|
||||
|
||||
// manager 是 Manager 的默认实现。
|
||||
type manager struct {
|
||||
log *logger.Logger
|
||||
enabled bool
|
||||
servers []*serverEntry // 稳定顺序,便于路由前缀匹配
|
||||
|
||||
mu sync.RWMutex
|
||||
routes map[string]route // FQName -> 路由缓存(ListTools 时刷新,CallTool 时按需补全)
|
||||
}
|
||||
|
||||
// NewManager 根据配置构建 MCP 工具子系统。
|
||||
// - log:go-common 日志对象(可为 nil,仅跳过调用日志);
|
||||
// - cfg:nil 或 Enabled=false 时返回的 Manager.Enabled() 恒为 false,其余方法安全返回空结果。
|
||||
//
|
||||
// 此处仅创建客户端对象(惰性连接),不会在启动时连接外部 MCP server,避免外部服务未就绪
|
||||
// 导致本服务启动失败。
|
||||
func NewManager(log *logger.Logger, cfg *config.MCPConfig) Manager {
|
||||
m := &manager{log: log, routes: make(map[string]route)}
|
||||
if cfg == nil {
|
||||
return m
|
||||
}
|
||||
m.enabled = cfg.Enabled
|
||||
|
||||
timeout := time.Duration(cfg.CallTimeoutSeconds) * time.Second
|
||||
for _, sc := range cfg.Servers {
|
||||
if !sc.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
m.servers = append(m.servers, newServerEntry(sc, NewClient(sc, timeout)))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// newServerEntry 组装 server 运行态(前缀归一化 + 白名单集合化)。
|
||||
func newServerEntry(sc config.MCPServerConfig, client Client) *serverEntry {
|
||||
prefix := strings.TrimSpace(sc.ToolNamePrefix)
|
||||
if prefix == "" {
|
||||
prefix = strings.TrimSpace(sc.Name)
|
||||
}
|
||||
allowed := make(map[string]bool, len(sc.AllowedTools))
|
||||
for _, t := range sc.AllowedTools {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
allowed[t] = true
|
||||
}
|
||||
}
|
||||
return &serverEntry{name: sc.Name, prefix: prefix, allowed: allowed, client: client}
|
||||
}
|
||||
|
||||
// Enabled 子系统启用且至少配置了一个 server 时才视为可用。
|
||||
func (m *manager) Enabled() bool {
|
||||
return m != nil && m.enabled && len(m.servers) > 0
|
||||
}
|
||||
|
||||
// ListTools 聚合全部 server 工具。单个 server 失败不影响其余 server(仅记录告警),保证整体健壮性。
|
||||
func (m *manager) ListTools(ctx context.Context) ([]Tool, error) {
|
||||
if !m.Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// 重建路由缓存,保证与最新工具列表一致。
|
||||
m.routes = make(map[string]route)
|
||||
|
||||
var out []Tool
|
||||
for _, srv := range m.servers {
|
||||
tools, err := srv.client.ListTools(ctx)
|
||||
if err != nil {
|
||||
m.logError("mcp list tools from server failed", err, map[string]any{"server": srv.name})
|
||||
continue
|
||||
}
|
||||
for _, t := range tools {
|
||||
if !srv.isAllowed(t.Name) {
|
||||
continue
|
||||
}
|
||||
fq := srv.prefix + toolNameSeparator + t.Name
|
||||
m.routes[fq] = route{server: srv, rawName: t.Name}
|
||||
out = append(out, Tool{
|
||||
FQName: fq,
|
||||
Name: t.Name,
|
||||
ServerName: srv.name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.InputSchema,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CallTool 按命名空间全名路由到对应 server 并调用。
|
||||
func (m *manager) CallTool(ctx context.Context, fqName string, args map[string]any) (ToolResult, error) {
|
||||
if !m.Enabled() {
|
||||
return ToolResult{}, fmt.Errorf("mcp subsystem disabled")
|
||||
}
|
||||
r, ok := m.resolve(fqName)
|
||||
if !ok {
|
||||
return ToolResult{}, fmt.Errorf("unknown mcp tool %q", fqName)
|
||||
}
|
||||
// 白名单二次校验,防止绕过 ListTools 直接调用未开放工具。
|
||||
if !r.server.isAllowed(r.rawName) {
|
||||
return ToolResult{}, fmt.Errorf("mcp tool %q is not allowed", fqName)
|
||||
}
|
||||
|
||||
callID := uuid.NewString()
|
||||
requestID := RequestIDFromContext(ctx)
|
||||
m.logToolCallAsync(toolCallLogSnapshot{
|
||||
phase: "request", requestID: requestID, callID: callID,
|
||||
fqName: fqName, server: r.server.name, rawTool: r.rawName,
|
||||
args: cloneAnyMap(args),
|
||||
})
|
||||
|
||||
res, err := r.server.client.CallTool(ctx, r.rawName, args)
|
||||
if err != nil {
|
||||
m.logToolCallAsync(toolCallLogSnapshot{
|
||||
phase: "error", requestID: requestID, callID: callID,
|
||||
fqName: fqName, server: r.server.name, rawTool: r.rawName,
|
||||
args: cloneAnyMap(args), errMsg: err.Error(),
|
||||
})
|
||||
return ToolResult{}, err
|
||||
}
|
||||
out := ToolResult{Text: res.Text, IsError: res.IsError, Raw: res.Raw}
|
||||
m.logToolCallAsync(toolCallLogSnapshot{
|
||||
phase: "response", requestID: requestID, callID: callID,
|
||||
fqName: fqName, server: r.server.name, rawTool: r.rawName,
|
||||
args: cloneAnyMap(args), hasResult: true, result: out,
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolve 解析命名空间全名到路由:优先读缓存,未命中则按 server 前缀推导(无需先 ListTools)。
|
||||
func (m *manager) resolve(fqName string) (route, bool) {
|
||||
m.mu.RLock()
|
||||
r, ok := m.routes[fqName]
|
||||
m.mu.RUnlock()
|
||||
if ok {
|
||||
return r, true
|
||||
}
|
||||
|
||||
for _, srv := range m.servers {
|
||||
pfx := srv.prefix + toolNameSeparator
|
||||
if strings.HasPrefix(fqName, pfx) {
|
||||
raw := strings.TrimPrefix(fqName, pfx)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
r := route{server: srv, rawName: raw}
|
||||
m.mu.Lock()
|
||||
m.routes[fqName] = r
|
||||
m.mu.Unlock()
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return route{}, false
|
||||
}
|
||||
|
||||
// Close 关闭全部底层 server 连接。
|
||||
func (m *manager) Close() error {
|
||||
var firstErr error
|
||||
for _, srv := range m.servers {
|
||||
if err := srv.client.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// --- 可选的请求追踪 ID(不强依赖任何业务 requestctx 包) ---
|
||||
|
||||
type requestIDKey struct{}
|
||||
|
||||
// WithRequestID 可选:将请求追踪 ID 写入 context,CallTool 的调用日志会附带该字段。
|
||||
// 典型用法:HTTP 层已经有 middleware.RequestID 生成的 ID 时,
|
||||
// `ctx = mcp.WithRequestID(ctx, middleware.GetRequestID(r.Context()))`。
|
||||
// 不调用时日志里的 request_id 为空,不影响功能。
|
||||
func WithRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, requestIDKey{}, id)
|
||||
}
|
||||
|
||||
// RequestIDFromContext 读取通过 WithRequestID 写入的请求追踪 ID;未设置时返回空字符串。
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(requestIDKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- 异步调用日志 ---
|
||||
|
||||
// toolCallLogSnapshot 异步日志快照;主流程只组装结构体,重活(截断/序列化/写日志)在 goroutine 内完成。
|
||||
type toolCallLogSnapshot struct {
|
||||
phase string
|
||||
requestID string
|
||||
callID string
|
||||
fqName string
|
||||
server string
|
||||
rawTool string
|
||||
args map[string]any
|
||||
hasResult bool
|
||||
result ToolResult
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (m *manager) logToolCallAsync(snap toolCallLogSnapshot) {
|
||||
if m.log == nil {
|
||||
return
|
||||
}
|
||||
go writeToolCallLog(m.log, snap)
|
||||
}
|
||||
|
||||
func (m *manager) logError(message string, err error, fields map[string]any) {
|
||||
if m.log == nil {
|
||||
return
|
||||
}
|
||||
if fields == nil {
|
||||
fields = make(map[string]any)
|
||||
}
|
||||
fields["error"] = err.Error()
|
||||
m.log.Error(message, fields)
|
||||
}
|
||||
|
||||
func writeToolCallLog(l *logger.Logger, snap toolCallLogSnapshot) {
|
||||
payload := map[string]any{
|
||||
"type": "mcp_tool_call",
|
||||
"phase": snap.phase,
|
||||
"request_id": snap.requestID,
|
||||
"call_id": snap.callID,
|
||||
"tool": snap.fqName,
|
||||
"server": snap.server,
|
||||
"raw_tool": snap.rawTool,
|
||||
}
|
||||
if len(snap.args) > 0 {
|
||||
payload["arguments"] = snap.args
|
||||
}
|
||||
if snap.hasResult {
|
||||
text := snap.result.Text
|
||||
totalRunes := len([]rune(text))
|
||||
truncated := totalRunes > toolCallLogMaxRunes
|
||||
if truncated {
|
||||
text = truncateRunes(text, toolCallLogMaxRunes)
|
||||
}
|
||||
resp := map[string]any{
|
||||
"is_error": snap.result.IsError,
|
||||
"text": text,
|
||||
"text_total_runes": totalRunes,
|
||||
"text_truncated": truncated,
|
||||
}
|
||||
if len(snap.result.Raw) > 0 {
|
||||
rawStr := string(snap.result.Raw)
|
||||
rawRunes := len([]rune(rawStr))
|
||||
if rawRunes > toolCallLogMaxRunes {
|
||||
resp["raw"] = truncateRunes(rawStr, toolCallLogMaxRunes)
|
||||
resp["raw_total_runes"] = rawRunes
|
||||
resp["raw_truncated"] = true
|
||||
} else {
|
||||
resp["raw"] = json.RawMessage(snap.result.Raw)
|
||||
}
|
||||
}
|
||||
payload["response"] = resp
|
||||
}
|
||||
if snap.errMsg != "" {
|
||||
payload["error"] = snap.errMsg
|
||||
}
|
||||
l.Info("mcp tool call", payload)
|
||||
}
|
||||
|
||||
func cloneAnyMap(m map[string]any) map[string]any {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
200
mcp/manager_test.go
Normal file
200
mcp/manager_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.toowon.com/jimmy/go-common/config"
|
||||
)
|
||||
|
||||
// fakeClient 是测试用的 Client 假实现,不依赖真实网络连接。
|
||||
type fakeClient struct {
|
||||
name string
|
||||
tools []ToolDescriptor
|
||||
listErr error
|
||||
callResult ToolCallResult
|
||||
callErr error
|
||||
closed bool
|
||||
|
||||
lastCalledTool string
|
||||
lastCalledArgs map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeClient) Name() string { return f.name }
|
||||
|
||||
func (f *fakeClient) ListTools(ctx context.Context) ([]ToolDescriptor, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
return f.tools, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) CallTool(ctx context.Context, name string, args map[string]any) (ToolCallResult, error) {
|
||||
f.lastCalledTool = name
|
||||
f.lastCalledArgs = args
|
||||
if f.callErr != nil {
|
||||
return ToolCallResult{}, f.callErr
|
||||
}
|
||||
return f.callResult, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) Close() error {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestManager(entries ...*serverEntry) *manager {
|
||||
return &manager{enabled: true, servers: entries, routes: make(map[string]route)}
|
||||
}
|
||||
|
||||
func TestManagerDisabledWithoutConfig(t *testing.T) {
|
||||
m := NewManager(nil, nil)
|
||||
if m.Enabled() {
|
||||
t.Fatal("Manager should be disabled when cfg is nil")
|
||||
}
|
||||
if _, err := m.CallTool(context.Background(), "x__y", nil); err == nil {
|
||||
t.Fatal("CallTool should error when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDisabledWhenNoServers(t *testing.T) {
|
||||
m := NewManager(nil, &config.MCPConfig{Enabled: true})
|
||||
if m.Enabled() {
|
||||
t.Fatal("Manager should be disabled when no servers configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerListToolsNamespacesAndFiltersAllowlist(t *testing.T) {
|
||||
fc := &fakeClient{name: "word", tools: []ToolDescriptor{
|
||||
{Name: "create_document", Description: "create a doc"},
|
||||
{Name: "delete_document", Description: "delete a doc"},
|
||||
}}
|
||||
entry := newServerEntry(config.MCPServerConfig{
|
||||
Name: "word",
|
||||
ToolNamePrefix: "word",
|
||||
AllowedTools: []string{"create_document"},
|
||||
}, fc)
|
||||
m := newTestManager(entry)
|
||||
|
||||
tools, err := m.ListTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool after allowlist filter, got %d", len(tools))
|
||||
}
|
||||
if tools[0].FQName != "word__create_document" {
|
||||
t.Fatalf("unexpected FQName: %s", tools[0].FQName)
|
||||
}
|
||||
if tools[0].ServerName != "word" {
|
||||
t.Fatalf("unexpected ServerName: %s", tools[0].ServerName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerListToolsSkipsFailingServer(t *testing.T) {
|
||||
ok := &fakeClient{name: "ok-server", tools: []ToolDescriptor{{Name: "t1"}}}
|
||||
bad := &fakeClient{name: "bad-server", listErr: errors.New("boom")}
|
||||
m := newTestManager(
|
||||
newServerEntry(config.MCPServerConfig{Name: "ok-server"}, ok),
|
||||
newServerEntry(config.MCPServerConfig{Name: "bad-server"}, bad),
|
||||
)
|
||||
|
||||
tools, err := m.ListTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool from healthy server, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCallToolRoutesByFQNameWithoutPriorListTools(t *testing.T) {
|
||||
fc := &fakeClient{name: "fetch-web", callResult: ToolCallResult{Text: "hello"}}
|
||||
entry := newServerEntry(config.MCPServerConfig{Name: "fetch-web", ToolNamePrefix: "fetch-web"}, fc)
|
||||
m := newTestManager(entry)
|
||||
|
||||
res, err := m.CallTool(context.Background(), "fetch-web__fetch_url", map[string]any{"url": "https://example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Text != "hello" {
|
||||
t.Fatalf("unexpected result text: %s", res.Text)
|
||||
}
|
||||
if fc.lastCalledTool != "fetch_url" {
|
||||
t.Fatalf("expected raw tool name %q, got %q", "fetch_url", fc.lastCalledTool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCallToolRejectsDisallowedTool(t *testing.T) {
|
||||
entry := newServerEntry(config.MCPServerConfig{
|
||||
Name: "word",
|
||||
ToolNamePrefix: "word",
|
||||
AllowedTools: []string{"create_document"},
|
||||
}, &fakeClient{name: "word"})
|
||||
m := newTestManager(entry)
|
||||
|
||||
if _, err := m.CallTool(context.Background(), "word__delete_document", nil); err == nil {
|
||||
t.Fatal("expected error for tool not in allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCallToolUnknownServerPrefix(t *testing.T) {
|
||||
entry := newServerEntry(config.MCPServerConfig{Name: "word", ToolNamePrefix: "word"}, &fakeClient{name: "word"})
|
||||
m := newTestManager(entry)
|
||||
|
||||
if _, err := m.CallTool(context.Background(), "unknown__tool", nil); err == nil {
|
||||
t.Fatal("expected error for unresolvable fq name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCloseClosesAllClients(t *testing.T) {
|
||||
fc1 := &fakeClient{name: "a"}
|
||||
fc2 := &fakeClient{name: "b"}
|
||||
m := newTestManager(
|
||||
newServerEntry(config.MCPServerConfig{Name: "a"}, fc1),
|
||||
newServerEntry(config.MCPServerConfig{Name: "b"}, fc2),
|
||||
)
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !fc1.closed || !fc2.closed {
|
||||
t.Fatal("Close should close all underlying clients")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if got := RequestIDFromContext(ctx); got != "" {
|
||||
t.Fatalf("expected empty request id, got %q", got)
|
||||
}
|
||||
ctx = WithRequestID(ctx, "req-123")
|
||||
if got := RequestIDFromContext(ctx); got != "req-123" {
|
||||
t.Fatalf("expected req-123, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTransportAndAuthHeader(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "streamable-http",
|
||||
"http": "streamable-http",
|
||||
"streamable-http": "streamable-http",
|
||||
"SSE": "sse",
|
||||
"weird": "weird",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeTransport(in); got != want {
|
||||
t.Errorf("normalizeTransport(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if got := authHeaderValue(""); got != "" {
|
||||
t.Fatalf("expected empty auth header, got %q", got)
|
||||
}
|
||||
if got := authHeaderValue("abc"); got != "Bearer abc" {
|
||||
t.Fatalf("expected Bearer prefix, got %q", got)
|
||||
}
|
||||
if got := authHeaderValue("Bearer abc"); got != "Bearer abc" {
|
||||
t.Fatalf("expected unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user