diff --git a/INTEGRATION.md b/INTEGRATION.md index 09669e8..912db43 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -88,6 +88,7 @@ go run ./cmd/server -config config.json -addr :8080 | 短信 | `SMS()` / `MustSMS()` | SendSMS / SendSMSAsync | | Excel | `Excel()` | 每次新建导出器 | | 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 | +| MCP 工具 | `MCP()` / `MustMCP()` | `ListTools` / `CallTool`(见 §8) | | HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` | | 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` | | 迁移 | `Migrator(dir)` | Up / Down / Status | @@ -184,6 +185,26 @@ 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` 段。 + --- ## 9. 最小 config.json @@ -222,6 +243,7 @@ tools.MD5("text") - 自行 `gorm.Open` / `redis.NewClient` - Factory 透传(`LogInfo`、`Success`、`Now` 等) - 请求路径滥用 `MustXxx` +- 把 MCP 工具路由/抽参/Prompt 编排逻辑塞进本库(应在业务侧基于 `mcp.Manager` 自行实现) --- diff --git a/README.md b/README.md index 6f073d0..fe58782 100644 --- a/README.md +++ b/README.md @@ -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.3.0 ``` ## 设计 @@ -33,6 +33,7 @@ go get git.toowon.com/jimmy/go-common@v1.2.0 | `i18n` | 多语言消息 | | `migration` | SQL 迁移 | | `tools` | 时间、加密、金额等 | +| `mcp` | MCP Client(多 server、`ListTools`/`CallTool`) | ## 文档 diff --git a/VERSION.md b/VERSION.md index eb04a1a..80b173e 100644 --- a/VERSION.md +++ b/VERSION.md @@ -18,16 +18,17 @@ 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.3.0** ## 版本历史 +- **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 diff --git a/config/config.go b/config/config.go index 2ee2869..98db4fe 100644 --- a/config/config.go +++ b/config/config.go @@ -20,6 +20,7 @@ type Config struct { Logger *LoggerConfig `json:"logger"` I18n *I18nConfig `json:"i18n"` RateLimit *RateLimitConfig `json:"rateLimit"` + MCP *MCPConfig `json:"mcp"` } // I18nConfig 国际化配置 @@ -325,6 +326,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 +537,13 @@ func (c *Config) setDefaults() { c.RateLimit.ByIP = true // 默认按IP限流 } } + + // MCP 默认值 + if c.MCP != nil { + if c.MCP.CallTimeoutSeconds <= 0 { + c.MCP.CallTimeoutSeconds = 60 + } + } } // GetDatabase 获取数据库配置 @@ -546,6 +598,11 @@ func (c *Config) GetI18n() *I18nConfig { return c.I18n } +// GetMCP 获取 MCP 配置 +func (c *Config) GetMCP() *MCPConfig { + return c.MCP +} + // GetDatabaseDSN 获取数据库连接字符串 func (c *Config) GetDatabaseDSN() (string, error) { if c.Database == nil { diff --git a/config/example.json b/config/example.json index d26990a..6d756c9 100644 --- a/config/example.json +++ b/config/example.json @@ -89,6 +89,30 @@ "period": 60, "byIP": true, "byUserID": false + }, + "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"] + } + ] } } diff --git a/examples/mcp_example.go b/examples/mcp_example.go new file mode 100644 index 0000000..e2ef138 --- /dev/null +++ b/examples/mcp_example.go @@ -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) +} diff --git a/factory/factory.go b/factory/factory.go index 3ef9be8..98e7279 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -13,6 +13,7 @@ import ( 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 +41,7 @@ type Factory struct { redis *redis.Client i18n *i18n.I18n chain *middleware.Chain + mcp mcp.Manager mu sync.Mutex } @@ -412,6 +414,29 @@ 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() +} + // Migrator 创建迁移器并加载指定目录下的 SQL 文件 func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) { db, err := f.getDatabase() diff --git a/factory/lifecycle.go b/factory/lifecycle.go index 5a86424..bbaab9f 100644 --- a/factory/lifecycle.go +++ b/factory/lifecycle.go @@ -7,6 +7,7 @@ import ( "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 +25,7 @@ const ( ModuleEmail Module = "email" ModuleSMS Module = "sms" ModuleI18n Module = "i18n" + ModuleMCP Module = "mcp" ) // MustInit 从配置文件初始化全局 Factory;失败则 panic(适合 main 启动) @@ -56,6 +58,8 @@ func (f *Factory) Warmup(modules ...Module) error { _, err = f.SMS() case ModuleI18n: _, err = f.I18n() + case ModuleMCP: + _, err = f.MCP() default: return fmt.Errorf("unknown module: %s", m) } @@ -104,6 +108,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 +189,12 @@ 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 +} diff --git a/factory/options.go b/factory/options.go index 15fe979..c53e4a9 100644 --- a/factory/options.go +++ b/factory/options.go @@ -4,6 +4,7 @@ import ( "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 +67,10 @@ 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 + } +} diff --git a/go.mod b/go.mod index 98f3dab..1249186 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,11 @@ module git.toowon.com/jimmy/go-common -go 1.24.0 - -toolchain go1.24.10 +go 1.25.0 require ( 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/redis/go-redis/v9 v9.17.1 github.com/xuri/excelize/v2 v2.10.0 golang.org/x/crypto v0.43.0 @@ -22,6 +21,7 @@ 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/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 +40,17 @@ 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 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 ) diff --git a/go.sum b/go.sum index a44a638..4182fb2 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,12 @@ 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/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 +56,8 @@ 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/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 +73,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 +92,24 @@ 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= 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.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= 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.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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= diff --git a/mcp/client.go b/mcp/client.go new file mode 100644 index 0000000..ed98808 --- /dev/null +++ b/mcp/client.go @@ -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 +} diff --git a/mcp/manager.go b/mcp/manager.go new file mode 100644 index 0000000..9778fff --- /dev/null +++ b/mcp/manager.go @@ -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 = __,防多 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]) + "…" +} diff --git a/mcp/manager_test.go b/mcp/manager_test.go new file mode 100644 index 0000000..a7919fc --- /dev/null +++ b/mcp/manager_test.go @@ -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) + } +}