2 Commits

19 changed files with 1948 additions and 9 deletions

View File

@@ -88,6 +88,8 @@ go run ./cmd/server -config config.json -addr :8080
| 短信 | `SMS()` / `MustSMS()` | SendSMS / SendSMSAsync | | 短信 | `SMS()` / `MustSMS()` | SendSMS / SendSMSAsync |
| Excel | `Excel()` | 每次新建导出器 | | Excel | `Excel()` | 每次新建导出器 |
| 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 | | 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 |
| MCP 工具 | `MCP()` / `MustMCP()` | `ListTools` / `CallTool`(见 §8 |
| 图形验证码 | `Captcha()` / `MustCaptcha()` | `Generate` / `Verify` / 登录防暴力(见 §8 |
| HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` | | HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` |
| 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` | | 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` |
| 迁移 | `Migrator(dir)` | Up / Down / Status | | 迁移 | `Migrator(dir)` | Up / Down / Status |
@@ -184,6 +186,61 @@ tools.Now()
tools.MD5("text") tools.MD5("text")
``` ```
### MCP 工具Model Context ProtocolClient
本库只做 **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 ## 9. 最小 config.json
@@ -222,6 +279,7 @@ tools.MD5("text")
- 自行 `gorm.Open` / `redis.NewClient` - 自行 `gorm.Open` / `redis.NewClient`
- Factory 透传(`LogInfo``Success``Now` 等) - Factory 透传(`LogInfo``Success``Now` 等)
- 请求路径滥用 `MustXxx` - 请求路径滥用 `MustXxx`
- 把 MCP 工具路由/抽参/Prompt 编排逻辑塞进本库(应在业务侧基于 `mcp.Manager` 自行实现)
--- ---

View File

@@ -8,7 +8,7 @@ git.toowon.com/jimmy/go-common
```bash ```bash
go env -w GOPRIVATE=git.toowon.com 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` | 多语言消息 | | `i18n` | 多语言消息 |
| `migration` | SQL 迁移 | | `migration` | SQL 迁移 |
| `tools` | 时间、加密、金额等 | | `tools` | 时间、加密、金额等 |
| `mcp` | MCP Client多 server、`ListTools`/`CallTool` |
| `captcha` | 图形验证码、登录防暴力(可选,`enabled` 开关) |
## 文档 ## 文档

View File

@@ -18,16 +18,18 @@ git push origin v1.2.0
## 消费方引用 ## 消费方引用
```bash ```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 # 开发 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` 模块MCPModel Context ProtocolClient`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.2.0** — DX`MustInit` / `Warmup` / `MustXxx` / `Close` / `NewHandler`Option 注入Excel 每次新建;`templates/server``ErrorData`;文档精简
- **v1.1.0** — 同上 DX 能力首次合入(见上) - **v1.1.0** — 同上 DX 能力首次合入(见上)
- **v1.0.0** — 初始Factory lazy getter、http.Handler 统一出参、logger / email / sms / storage / middleware / migration / tools / i18n / excel - **v1.0.0** — 初始Factory lazy getter、http.Handler 统一出参、logger / email / sms / storage / middleware / migration / tools / i18n / excel

307
captcha/captcha.go Normal file
View 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
View 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
View 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
View 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
}

View File

@@ -20,6 +20,8 @@ type Config struct {
Logger *LoggerConfig `json:"logger"` Logger *LoggerConfig `json:"logger"`
I18n *I18nConfig `json:"i18n"` I18n *I18nConfig `json:"i18n"`
RateLimit *RateLimitConfig `json:"rateLimit"` RateLimit *RateLimitConfig `json:"rateLimit"`
MCP *MCPConfig `json:"mcp"`
Captcha *CaptchaConfig `json:"captcha"`
} }
// I18nConfig 国际化配置 // I18nConfig 国际化配置
@@ -307,6 +309,48 @@ func (c *LoggerConfig) IsAsync() bool {
return *c.Async 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 | neverlogin 场景由 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 限流配置 // RateLimitConfig 限流配置
type RateLimitConfig struct { type RateLimitConfig struct {
// Enable 是否启用限流 // Enable 是否启用限流
@@ -325,6 +369,50 @@ type RateLimitConfig struct {
ByUserID bool `json:"byUserID"` ByUserID bool `json:"byUserID"`
} }
// MCPConfig MCPModel 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 是否启用该 servernil 默认 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 从文件加载配置 // LoadFromFile 从文件加载配置
// filePath: 配置文件路径(支持绝对路径和相对路径) // filePath: 配置文件路径(支持绝对路径和相对路径)
func LoadFromFile(filePath string) (*Config, error) { func LoadFromFile(filePath string) (*Config, error) {
@@ -492,6 +580,41 @@ func (c *Config) setDefaults() {
c.RateLimit.ByIP = true // 默认按IP限流 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 获取数据库配置 // GetDatabase 获取数据库配置
@@ -546,6 +669,16 @@ func (c *Config) GetI18n() *I18nConfig {
return c.I18n return c.I18n
} }
// GetMCP 获取 MCP 配置
func (c *Config) GetMCP() *MCPConfig {
return c.MCP
}
// GetCaptcha 获取图形验证码配置
func (c *Config) GetCaptcha() *CaptchaConfig {
return c.Captcha
}
// GetDatabaseDSN 获取数据库连接字符串 // GetDatabaseDSN 获取数据库连接字符串
func (c *Config) GetDatabaseDSN() (string, error) { func (c *Config) GetDatabaseDSN() (string, error) {
if c.Database == nil { if c.Database == nil {

View File

@@ -89,6 +89,47 @@
"period": 60, "period": 60,
"byIP": true, "byIP": true,
"byUserID": false "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"]
}
]
} }
} }

View 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
View 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"
)
// 本示例演示 MCPModel Context ProtocolClient 的用法:
// - 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)
}

View File

@@ -7,12 +7,14 @@ import (
"sync" "sync"
"time" "time"
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/email"
"git.toowon.com/jimmy/go-common/excel" "git.toowon.com/jimmy/go-common/excel"
commonhttp "git.toowon.com/jimmy/go-common/http" commonhttp "git.toowon.com/jimmy/go-common/http"
"git.toowon.com/jimmy/go-common/i18n" "git.toowon.com/jimmy/go-common/i18n"
"git.toowon.com/jimmy/go-common/logger" "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/middleware"
"git.toowon.com/jimmy/go-common/migration" "git.toowon.com/jimmy/go-common/migration"
"git.toowon.com/jimmy/go-common/sms" "git.toowon.com/jimmy/go-common/sms"
@@ -40,6 +42,8 @@ type Factory struct {
redis *redis.Client redis *redis.Client
i18n *i18n.I18n i18n *i18n.I18n
chain *middleware.Chain chain *middleware.Chain
mcp mcp.Manager
captcha captcha.Manager
mu sync.Mutex mu sync.Mutex
} }
@@ -412,6 +416,64 @@ func (f *Factory) getI18nUnlocked() (*i18n.I18n, error) {
return i, nil 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 文件 // Migrator 创建迁移器并加载指定目录下的 SQL 文件
func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) { func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) {
db, err := f.getDatabase() db, err := f.getDatabase()

View File

@@ -4,9 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/email"
"git.toowon.com/jimmy/go-common/i18n" "git.toowon.com/jimmy/go-common/i18n"
"git.toowon.com/jimmy/go-common/logger" "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/sms"
"git.toowon.com/jimmy/go-common/storage" "git.toowon.com/jimmy/go-common/storage"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -24,6 +26,8 @@ const (
ModuleEmail Module = "email" ModuleEmail Module = "email"
ModuleSMS Module = "sms" ModuleSMS Module = "sms"
ModuleI18n Module = "i18n" ModuleI18n Module = "i18n"
ModuleMCP Module = "mcp"
ModuleCaptcha Module = "captcha"
) )
// MustInit 从配置文件初始化全局 Factory失败则 panic适合 main 启动) // MustInit 从配置文件初始化全局 Factory失败则 panic适合 main 启动)
@@ -56,6 +60,10 @@ func (f *Factory) Warmup(modules ...Module) error {
_, err = f.SMS() _, err = f.SMS()
case ModuleI18n: case ModuleI18n:
_, err = f.I18n() _, err = f.I18n()
case ModuleMCP:
_, err = f.MCP()
case ModuleCaptcha:
_, err = f.Captcha()
default: default:
return fmt.Errorf("unknown module: %s", m) return fmt.Errorf("unknown module: %s", m)
} }
@@ -104,6 +112,12 @@ func (f *Factory) Close() error {
} }
f.db = nil 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 f.logger != nil {
if err := f.logger.Close(); err != nil { if err := f.logger.Close(); err != nil {
errs = append(errs, fmt.Errorf("logger: %w", err)) errs = append(errs, fmt.Errorf("logger: %w", err))
@@ -179,3 +193,21 @@ func (f *Factory) MustI18n() *i18n.I18n {
} }
return i 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
}

View File

@@ -1,9 +1,11 @@
package factory package factory
import ( import (
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/email"
"git.toowon.com/jimmy/go-common/i18n" "git.toowon.com/jimmy/go-common/i18n"
"git.toowon.com/jimmy/go-common/logger" "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/middleware"
"git.toowon.com/jimmy/go-common/sms" "git.toowon.com/jimmy/go-common/sms"
"git.toowon.com/jimmy/go-common/storage" "git.toowon.com/jimmy/go-common/storage"
@@ -66,3 +68,17 @@ func WithMiddlewareChain(c *middleware.Chain) Option {
f.chain = c 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
View File

@@ -1,12 +1,13 @@
module git.toowon.com/jimmy/go-common module git.toowon.com/jimmy/go-common
go 1.24.0 go 1.25.0
toolchain go1.24.10
require ( require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.0.97 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/redis/go-redis/v9 v9.17.1
github.com/xuri/excelize/v2 v2.10.0 github.com/xuri/excelize/v2 v2.10.0
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.43.0
@@ -22,6 +23,8 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // 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/richardlehane/msoleps v1.0.4 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // 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/tiendc/go-deepcopy v1.7.1 // indirect
github.com/tinylib/msgp v1.3.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect
github.com/xuri/efp v0.0.1 // indirect github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // 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/net v0.46.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.17.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 golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

62
go.sum
View File

@@ -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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 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.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 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/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 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= 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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/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 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= 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 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 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 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= 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 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

299
mcp/client.go Normal file
View File

@@ -0,0 +1,299 @@
// Package mcp 提供 MCPModel Context Protocol**Client** 能力:连接外部 MCP
// server容器/服务),发现工具、调用工具。
//
// 定位(见 .cursor/skills/go-common/SKILL.md本包只做「连接 + 调用」基础设施,
// 不包含任何路由 / 参数抽取 / 编排逻辑 —— 那属于业务侧的 Prompt/Agent 编排,
// 由消费方基于 Manager 暴露的 ListTools/CallTool 自行实现(可参考各业务项目里的
// ToolOrchestrator 写法)。
//
// 传输基于官方 SDKgithub.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 客户端;否则返回 nilSDK 用默认客户端)。
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
View 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 工具子系统。
// - loggo-common 日志对象(可为 nil仅跳过调用日志
// - cfgnil 或 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 写入 contextCallTool 的调用日志会附带该字段。
// 典型用法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
View 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)
}
}