Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: integrate Model Context Protocol (MCP) support with configurati… (#12)

* feat: integrate Model Context Protocol (MCP) support with configuration and tool registration

* feat: enhance MCP server integration with runtime snapshot and tool registration updates

* feat: add MCPConfigFromReader function for local viper instance support and refactor MCPConfigFromViper

authored by

yiplee and committed by
GitHub
49de158d a2500ac5

+578 -9
+18
assets/config/config.example.yaml
··· 227 227 # These are added on top of the built-in safe env allowlist. 228 228 injected_env_vars: [] # e.g. ["OPENAI_API_BASE", "HTTP_TIMEOUT"] 229 229 230 + # MCP (Model Context Protocol) servers. 231 + # Connect to external MCP tool servers. Tools are namespaced as mcp_<name>__<tool>. 232 + mcp: 233 + servers: [] 234 + # - name: "filesystem" 235 + # transport: "stdio" # stdio (default) | sse 236 + # command: "npx" 237 + # args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] 238 + # env: 239 + # NODE_OPTIONS: "--max-old-space-size=4096" 240 + # allowed_tools: [] # empty = all tools from this server 241 + # - name: "remote" 242 + # transport: "sse" 243 + # url: "https://mcp.example.com/sse" 244 + # headers: # custom HTTP headers (e.g. auth); values support ${ENV_VAR} 245 + # Authorization: "Bearer ${MCP_REMOTE_TOKEN}" 246 + # allowed_tools: ["search"] 247 + 230 248 # Markdown-based memory 231 249 memory: 232 250 # Enable memory (Telegram only).
+3
cmd/mistermorph/defaults.go
··· 128 128 viper.SetDefault("secrets.allow_profiles", []string{}) 129 129 viper.SetDefault("auth_profiles", map[string]any{}) 130 130 131 + // MCP (Model Context Protocol) servers. 132 + viper.SetDefault("mcp.servers", []map[string]any{}) 133 + 131 134 // Guard (M1). 132 135 viper.SetDefault("guard.enabled", true) 133 136 viper.SetDefault("guard.network.url_fetch.allowed_url_prefixes", []string{"https://"})
+29 -3
cmd/mistermorph/root.go
··· 25 25 "github.com/quailyquaily/mistermorph/internal/llmstats" 26 26 "github.com/quailyquaily/mistermorph/internal/llmutil" 27 27 "github.com/quailyquaily/mistermorph/internal/logutil" 28 + "github.com/quailyquaily/mistermorph/internal/mcphost" 28 29 "github.com/quailyquaily/mistermorph/internal/pathutil" 29 30 "github.com/quailyquaily/mistermorph/internal/skillsutil" 30 31 "github.com/quailyquaily/mistermorph/llm" ··· 313 314 } 314 315 315 316 type registryRuntimeResolver struct { 316 - once sync.Once 317 - cfg registryConfig 317 + once sync.Once 318 + cfg registryConfig 319 + mcpOnce sync.Once 320 + mcpHost *mcphost.Host 318 321 } 319 322 320 323 func newRegistryRuntimeResolver() *registryRuntimeResolver { ··· 332 335 } 333 336 334 337 func (r *registryRuntimeResolver) Registry() *tools.Registry { 335 - return buildRegistryFromConfig(r.Config(), slog.Default()) 338 + reg := buildRegistryFromConfig(r.Config(), slog.Default()) 339 + r.ensureMCP() 340 + if r.mcpHost != nil { 341 + for _, t := range r.mcpHost.Tools() { 342 + reg.Register(t) 343 + } 344 + } 345 + return reg 346 + } 347 + 348 + func (r *registryRuntimeResolver) ensureMCP() { 349 + r.mcpOnce.Do(func() { 350 + logger := slog.Default() 351 + configs := mcphost.MCPConfigFromViper() 352 + if len(configs) == 0 { 353 + return 354 + } 355 + host, err := mcphost.Connect(context.Background(), configs, logger) 356 + if err != nil { 357 + logger.Warn("mcp_init_failed", "err", err) 358 + return 359 + } 360 + r.mcpHost = host 361 + }) 336 362 } 337 363 338 364 type guardRuntimeResolver struct {
+6 -1
go.mod
··· 8 8 github.com/google/uuid v1.6.0 9 9 github.com/gorilla/websocket v1.5.3 10 10 github.com/lyricat/goutils v1.2.3 11 + github.com/modelcontextprotocol/go-sdk v1.4.0 11 12 github.com/nickalie/go-webpbin v0.0.0-20220110095747-f10016bf2dc1 12 13 github.com/quailyquaily/uniai v0.1.4 13 14 github.com/spf13/cobra v1.8.1 ··· 27 28 github.com/dsnet/compress v0.0.1 // indirect 28 29 github.com/fsnotify/fsnotify v1.7.0 // indirect 29 30 github.com/golang/snappy v0.0.4 // indirect 30 - github.com/google/go-cmp v0.7.0 // indirect 31 + github.com/google/jsonschema-go v0.4.2 // indirect 31 32 github.com/hashicorp/hcl v1.0.0 // indirect 32 33 github.com/inconshreveable/mousetrap v1.1.0 // indirect 33 34 github.com/jmespath/go-jmespath v0.4.0 // indirect ··· 41 42 github.com/pierrec/lz4 v2.6.1+incompatible // indirect 42 43 github.com/sagikazarmark/locafero v0.4.0 // indirect 43 44 github.com/sagikazarmark/slog-shim v0.1.0 // indirect 45 + github.com/segmentio/asm v1.2.0 // indirect 46 + github.com/segmentio/encoding v0.5.3 // indirect 44 47 github.com/shopspring/decimal v1.4.0 // indirect 45 48 github.com/sourcegraph/conc v0.3.0 // indirect 46 49 github.com/spf13/afero v1.11.0 // indirect ··· 53 56 github.com/tidwall/sjson v1.2.5 // indirect 54 57 github.com/ulikunitz/xz v0.5.10 // indirect 55 58 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect 59 + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 56 60 go.uber.org/multierr v1.11.0 // indirect 57 61 golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect 62 + golang.org/x/oauth2 v0.34.0 // indirect 58 63 golang.org/x/text v0.33.0 // indirect 59 64 gopkg.in/ini.v1 v1.67.0 // indirect 60 65 gopkg.in/yaml.v2 v2.4.0 // indirect
+16 -2
go.sum
··· 21 21 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 22 22 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 23 23 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 24 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 25 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 24 + github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 25 + github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 26 26 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 27 27 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 28 28 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 29 29 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 30 + github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= 31 + github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 30 32 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 31 33 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 32 34 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= ··· 55 57 github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= 56 58 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 57 59 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 60 + github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= 61 + github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= 58 62 github.com/nickalie/go-binwrapper v0.0.0-20190114141239-525121d43c84 h1:/6MoQlTdk1eAi0J9O89ypO8umkp+H7mpnSF2ggSL62Q= 59 63 github.com/nickalie/go-binwrapper v0.0.0-20190114141239-525121d43c84/go.mod h1:Eeech2fhQ/E4bS8cdc3+SGABQ+weQYGyWBvZ/mNr5uY= 60 64 github.com/nickalie/go-webpbin v0.0.0-20220110095747-f10016bf2dc1 h1:9awJsNP+gYOGCr3pQu9i217bCNsVwoQCmD3h7CYwxOw= ··· 81 85 github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 82 86 github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 83 87 github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 88 + github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 89 + github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 90 + github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= 91 + github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= 84 92 github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 85 93 github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 86 94 github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= ··· 123 131 github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 124 132 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= 125 133 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= 134 + github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 135 + github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 126 136 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 127 137 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 128 138 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= ··· 135 145 golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= 136 146 golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= 137 147 golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 148 + golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 149 + golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 138 150 golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= 139 151 golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 140 152 golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= ··· 143 155 golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= 144 156 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 145 157 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 158 + golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= 159 + golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= 146 160 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 147 161 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 148 162 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+3
integration/defaults.go
··· 111 111 v.SetDefault("secrets.allow_profiles", []string{}) 112 112 v.SetDefault("auth_profiles", map[string]any{}) 113 113 114 + // MCP. 115 + v.SetDefault("mcp.servers", []map[string]any{}) 116 + 114 117 // Guard. 115 118 v.SetDefault("guard.enabled", true) 116 119 v.SetDefault("guard.network.url_fetch.allowed_url_prefixes", []string{"https://"})
+17 -1
integration/runtime.go
··· 11 11 "github.com/quailyquaily/mistermorph/agent" 12 12 "github.com/quailyquaily/mistermorph/internal/llminspect" 13 13 "github.com/quailyquaily/mistermorph/internal/llmutil" 14 + "github.com/quailyquaily/mistermorph/internal/mcphost" 14 15 "github.com/quailyquaily/mistermorph/internal/promptprofile" 15 16 "github.com/quailyquaily/mistermorph/internal/toolsutil" 16 17 "github.com/quailyquaily/mistermorph/tools" ··· 177 178 reg = rt.buildRegistry(snap.Registry, logger) 178 179 } 179 180 181 + var mcpCleanup func() error 182 + mh, err := mcphost.RegisterTools(ctx, snap.MCPServers, reg, logger) 183 + if err != nil { 184 + logger.Warn("mcp_init_failed", "err", err) 185 + } 186 + if mh != nil { 187 + mcpCleanup = mh.Close 188 + } 189 + 180 190 planEnabled := rt.features.PlanTool && snap.Registry.ToolsPlanCreateEnabled && rt.isBuiltinToolSelected(toolsutil.BuiltinPlanCreate) 181 191 todoEnabled := snap.Registry.ToolsTodoUpdateEnabled && rt.isBuiltinToolSelected(toolsutil.BuiltinTodoUpdate) 182 192 planClient := client ··· 248 258 Engine: engine, 249 259 Model: model, 250 260 Cleanup: func() error { 251 - return inspectCleanup() 261 + firstErr := inspectCleanup() 262 + if mcpCleanup != nil { 263 + if err := mcpCleanup(); err != nil && firstErr == nil { 264 + firstErr = err 265 + } 266 + } 267 + return firstErr 252 268 }, 253 269 }, nil 254 270 }
+2
integration/runtime_snapshot.go
··· 8 8 "github.com/quailyquaily/mistermorph/guard" 9 9 "github.com/quailyquaily/mistermorph/internal/channelopts" 10 10 "github.com/quailyquaily/mistermorph/internal/llmutil" 11 + "github.com/quailyquaily/mistermorph/internal/mcphost" 11 12 "github.com/quailyquaily/mistermorph/internal/skillsutil" 12 13 "github.com/quailyquaily/mistermorph/secrets" 13 14 ) ··· 24 25 Guard guardSnapshot 25 26 Telegram channelopts.TelegramConfig 26 27 Slack channelopts.SlackConfig 28 + MCPServers []mcphost.ServerConfig 27 29 } 28 30 29 31 type registrySnapshot struct {
+4 -2
integration/runtime_snapshot_loader.go
··· 10 10 "github.com/quailyquaily/mistermorph/internal/channelopts" 11 11 "github.com/quailyquaily/mistermorph/internal/llmutil" 12 12 "github.com/quailyquaily/mistermorph/internal/logutil" 13 + "github.com/quailyquaily/mistermorph/internal/mcphost" 13 14 "github.com/quailyquaily/mistermorph/internal/pathutil" 14 15 "github.com/quailyquaily/mistermorph/internal/skillsutil" 15 16 "github.com/quailyquaily/mistermorph/internal/statepaths" ··· 132 133 }, 133 134 Dir: pathutil.ResolveStateChildDir(fileStateDir, strings.TrimSpace(v.GetString("guard.dir_name")), "guard"), 134 135 }, 135 - Telegram: channelopts.TelegramConfigFromReader(v), 136 - Slack: channelopts.SlackConfigFromReader(v), 136 + Telegram: channelopts.TelegramConfigFromReader(v), 137 + Slack: channelopts.SlackConfigFromReader(v), 138 + MCPServers: mcphost.MCPConfigFromReader(v), 137 139 } 138 140 } 139 141
+151
internal/mcphost/config.go
··· 1 + package mcphost 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strings" 7 + 8 + "github.com/spf13/viper" 9 + ) 10 + 11 + type ServerConfig struct { 12 + Name string 13 + Transport string // "stdio" | "sse" 14 + Command string // stdio only 15 + Args []string // stdio only 16 + Env map[string]string // stdio only 17 + URL string // sse only 18 + Headers map[string]string // sse only: custom HTTP headers (auth etc.) 19 + AllowedTools []string // whitelist; empty = all 20 + } 21 + 22 + func (c *ServerConfig) Validate() error { 23 + if strings.TrimSpace(c.Name) == "" { 24 + return fmt.Errorf("mcp server name is required") 25 + } 26 + transport := strings.ToLower(strings.TrimSpace(c.Transport)) 27 + if transport == "" { 28 + transport = "stdio" 29 + } 30 + switch transport { 31 + case "stdio": 32 + if strings.TrimSpace(c.Command) == "" { 33 + return fmt.Errorf("mcp server %q: command is required for stdio transport", c.Name) 34 + } 35 + case "sse": 36 + if strings.TrimSpace(c.URL) == "" { 37 + return fmt.Errorf("mcp server %q: url is required for sse transport", c.Name) 38 + } 39 + default: 40 + return fmt.Errorf("mcp server %q: unsupported transport %q (supported: stdio, sse)", c.Name, transport) 41 + } 42 + return nil 43 + } 44 + 45 + // ExpandedHeaders returns headers with ${ENV_VAR} references expanded. 46 + func (c *ServerConfig) ExpandedHeaders() map[string]string { 47 + if len(c.Headers) == 0 { 48 + return nil 49 + } 50 + out := make(map[string]string, len(c.Headers)) 51 + for k, v := range c.Headers { 52 + out[k] = os.ExpandEnv(v) 53 + } 54 + return out 55 + } 56 + 57 + // AllowedToolSet returns a set of allowed tool names for fast lookup. 58 + // Returns nil if no whitelist is configured (all tools allowed). 59 + func (c *ServerConfig) AllowedToolSet() map[string]bool { 60 + if len(c.AllowedTools) == 0 { 61 + return nil 62 + } 63 + set := make(map[string]bool, len(c.AllowedTools)) 64 + for _, name := range c.AllowedTools { 65 + name = strings.TrimSpace(name) 66 + if name != "" { 67 + set[name] = true 68 + } 69 + } 70 + if len(set) == 0 { 71 + return nil 72 + } 73 + return set 74 + } 75 + 76 + // MCPConfigFromViper reads MCP server configs from the global viper instance. 77 + func MCPConfigFromViper() []ServerConfig { 78 + return parseMCPServers(viper.Get("mcp.servers")) 79 + } 80 + 81 + // MCPConfigFromReader reads MCP server configs from a local viper instance, 82 + // preserving the integration library's config isolation guarantees. 83 + func MCPConfigFromReader(v *viper.Viper) []ServerConfig { 84 + if v == nil { 85 + return nil 86 + } 87 + return parseMCPServers(v.Get("mcp.servers")) 88 + } 89 + 90 + func parseMCPServers(raw any) []ServerConfig { 91 + if raw == nil { 92 + return nil 93 + } 94 + 95 + items, ok := raw.([]any) 96 + if !ok { 97 + return nil 98 + } 99 + 100 + var configs []ServerConfig 101 + for _, item := range items { 102 + m, ok := item.(map[string]any) 103 + if !ok { 104 + continue 105 + } 106 + cfg := ServerConfig{ 107 + Name: asString(m["name"]), 108 + Transport: asString(m["transport"]), 109 + Command: asString(m["command"]), 110 + URL: asString(m["url"]), 111 + } 112 + if args, ok := m["args"].([]any); ok { 113 + for _, a := range args { 114 + if s, ok := a.(string); ok { 115 + cfg.Args = append(cfg.Args, s) 116 + } 117 + } 118 + } 119 + if env, ok := m["env"].(map[string]any); ok { 120 + cfg.Env = make(map[string]string, len(env)) 121 + for k, v := range env { 122 + cfg.Env[k] = fmt.Sprintf("%v", v) 123 + } 124 + } 125 + if headers, ok := m["headers"].(map[string]any); ok { 126 + cfg.Headers = make(map[string]string, len(headers)) 127 + for k, v := range headers { 128 + cfg.Headers[k] = fmt.Sprintf("%v", v) 129 + } 130 + } 131 + if allowed, ok := m["allowed_tools"].([]any); ok { 132 + for _, a := range allowed { 133 + if s, ok := a.(string); ok { 134 + cfg.AllowedTools = append(cfg.AllowedTools, s) 135 + } 136 + } 137 + } 138 + configs = append(configs, cfg) 139 + } 140 + return configs 141 + } 142 + 143 + func asString(v any) string { 144 + if v == nil { 145 + return "" 146 + } 147 + if s, ok := v.(string); ok { 148 + return s 149 + } 150 + return fmt.Sprintf("%v", v) 151 + }
+226
internal/mcphost/host.go
··· 1 + package mcphost 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "os/exec" 10 + "strings" 11 + "sync" 12 + 13 + "github.com/modelcontextprotocol/go-sdk/mcp" 14 + "github.com/quailyquaily/mistermorph/tools" 15 + ) 16 + 17 + type Host struct { 18 + mu sync.Mutex 19 + sessions []*serverSession 20 + tools []tools.Tool 21 + logger *slog.Logger 22 + } 23 + 24 + type serverSession struct { 25 + name string 26 + session *mcp.ClientSession 27 + } 28 + 29 + // Connect creates an MCPHost, connects to all configured MCP servers, 30 + // discovers tools, and returns the host. Individual server failures are 31 + // logged and skipped. 32 + func Connect(ctx context.Context, configs []ServerConfig, logger *slog.Logger) (*Host, error) { 33 + if logger == nil { 34 + logger = slog.Default() 35 + } 36 + if len(configs) == 0 { 37 + return nil, nil 38 + } 39 + 40 + h := &Host{logger: logger} 41 + 42 + for i := range configs { 43 + cfg := &configs[i] 44 + if err := cfg.Validate(); err != nil { 45 + logger.Warn("mcp_server_config_invalid", "server", cfg.Name, "err", err) 46 + continue 47 + } 48 + 49 + session, serverTools, err := h.connectServer(ctx, cfg) 50 + if err != nil { 51 + logger.Warn("mcp_server_connect_failed", "server", cfg.Name, "err", err) 52 + continue 53 + } 54 + 55 + h.sessions = append(h.sessions, &serverSession{ 56 + name: cfg.Name, 57 + session: session, 58 + }) 59 + h.tools = append(h.tools, serverTools...) 60 + 61 + toolNames := make([]string, len(serverTools)) 62 + for i, t := range serverTools { 63 + toolNames[i] = t.Name() 64 + } 65 + logger.Info("mcp_tools_loaded", 66 + "server", cfg.Name, 67 + "count", len(serverTools), 68 + "tools", toolNames, 69 + ) 70 + } 71 + 72 + if len(h.sessions) == 0 { 73 + return nil, nil 74 + } 75 + 76 + return h, nil 77 + } 78 + 79 + func (h *Host) connectServer(ctx context.Context, cfg *ServerConfig) (*mcp.ClientSession, []tools.Tool, error) { 80 + client := mcp.NewClient( 81 + &mcp.Implementation{Name: "mistermorph", Version: "1.0"}, 82 + nil, 83 + ) 84 + 85 + transport, err := h.buildTransport(cfg) 86 + if err != nil { 87 + return nil, nil, err 88 + } 89 + 90 + session, err := client.Connect(ctx, transport, nil) 91 + if err != nil { 92 + return nil, nil, fmt.Errorf("connect: %w", err) 93 + } 94 + 95 + toolsList, err := session.ListTools(ctx, nil) 96 + if err != nil { 97 + _ = session.Close() 98 + return nil, nil, fmt.Errorf("list tools: %w", err) 99 + } 100 + 101 + allowedSet := cfg.AllowedToolSet() 102 + 103 + var adapted []tools.Tool 104 + for _, t := range toolsList.Tools { 105 + if allowedSet != nil && !allowedSet[t.Name] { 106 + continue 107 + } 108 + adapter, err := newToolAdapter(cfg.Name, t, session) 109 + if err != nil { 110 + h.logger.Warn("mcp_tool_adapt_failed", 111 + "server", cfg.Name, 112 + "tool", t.Name, 113 + "err", err, 114 + ) 115 + continue 116 + } 117 + adapted = append(adapted, adapter) 118 + } 119 + 120 + return session, adapted, nil 121 + } 122 + 123 + func (h *Host) buildTransport(cfg *ServerConfig) (mcp.Transport, error) { 124 + transport := strings.ToLower(strings.TrimSpace(cfg.Transport)) 125 + if transport == "" { 126 + transport = "stdio" 127 + } 128 + 129 + switch transport { 130 + case "stdio": 131 + return h.buildStdioTransport(cfg), nil 132 + case "sse": 133 + return h.buildSSETransport(cfg), nil 134 + default: 135 + return nil, fmt.Errorf("unsupported transport: %s", transport) 136 + } 137 + } 138 + 139 + func (h *Host) buildStdioTransport(cfg *ServerConfig) mcp.Transport { 140 + cmd := exec.Command(cfg.Command, cfg.Args...) 141 + if len(cfg.Env) > 0 { 142 + cmd.Env = os.Environ() 143 + for k, v := range cfg.Env { 144 + cmd.Env = append(cmd.Env, k+"="+v) 145 + } 146 + } 147 + return &mcp.CommandTransport{Command: cmd} 148 + } 149 + 150 + func (h *Host) buildSSETransport(cfg *ServerConfig) mcp.Transport { 151 + transport := &mcp.SSEClientTransport{ 152 + Endpoint: cfg.URL, 153 + } 154 + 155 + headers := cfg.ExpandedHeaders() 156 + if len(headers) > 0 { 157 + transport.HTTPClient = &http.Client{ 158 + Transport: &headerInjector{ 159 + base: http.DefaultTransport, 160 + headers: headers, 161 + }, 162 + } 163 + } 164 + 165 + return transport 166 + } 167 + 168 + // Tools returns all adapted MCP tools. 169 + func (h *Host) Tools() []tools.Tool { 170 + if h == nil { 171 + return nil 172 + } 173 + return h.tools 174 + } 175 + 176 + // Close gracefully closes all MCP sessions. 177 + func (h *Host) Close() error { 178 + if h == nil { 179 + return nil 180 + } 181 + h.mu.Lock() 182 + defer h.mu.Unlock() 183 + 184 + var firstErr error 185 + for _, s := range h.sessions { 186 + if err := s.session.Close(); err != nil && firstErr == nil { 187 + firstErr = err 188 + } 189 + } 190 + h.sessions = nil 191 + h.tools = nil 192 + return firstErr 193 + } 194 + 195 + // RegisterTools connects to all configured MCP servers and registers 196 + // discovered tools into reg. Returns the host (for cleanup) or nil if 197 + // no MCP servers are configured. 198 + func RegisterTools(ctx context.Context, configs []ServerConfig, reg *tools.Registry, logger *slog.Logger) (*Host, error) { 199 + if len(configs) == 0 { 200 + return nil, nil 201 + } 202 + host, err := Connect(ctx, configs, logger) 203 + if err != nil { 204 + return nil, err 205 + } 206 + if host == nil { 207 + return nil, nil 208 + } 209 + for _, t := range host.Tools() { 210 + reg.Register(t) 211 + } 212 + return host, nil 213 + } 214 + 215 + // headerInjector is an http.RoundTripper that injects custom headers. 216 + type headerInjector struct { 217 + base http.RoundTripper 218 + headers map[string]string 219 + } 220 + 221 + func (h *headerInjector) RoundTrip(req *http.Request) (*http.Response, error) { 222 + for k, v := range h.headers { 223 + req.Header.Set(k, v) 224 + } 225 + return h.base.RoundTrip(req) 226 + }
+103
internal/mcphost/tool_adapter.go
··· 1 + package mcphost 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "strings" 8 + 9 + "github.com/modelcontextprotocol/go-sdk/mcp" 10 + ) 11 + 12 + // ToolAdapter wraps an MCP server tool as a tools.Tool implementation. 13 + type ToolAdapter struct { 14 + serverName string 15 + originalName string 16 + description string 17 + inputSchema string 18 + session *mcp.ClientSession 19 + } 20 + 21 + // NamespacedToolName returns "mcp_<server>__<tool>". 22 + func NamespacedToolName(serverName, toolName string) string { 23 + return "mcp_" + serverName + "__" + toolName 24 + } 25 + 26 + func newToolAdapter(serverName string, tool *mcp.Tool, session *mcp.ClientSession) (*ToolAdapter, error) { 27 + schemaJSON := "{}" 28 + if tool.InputSchema != nil { 29 + b, err := json.Marshal(tool.InputSchema) 30 + if err != nil { 31 + return nil, fmt.Errorf("marshal input schema for tool %q: %w", tool.Name, err) 32 + } 33 + schemaJSON = string(b) 34 + } 35 + 36 + return &ToolAdapter{ 37 + serverName: serverName, 38 + originalName: tool.Name, 39 + description: strings.TrimSpace(tool.Description), 40 + inputSchema: schemaJSON, 41 + session: session, 42 + }, nil 43 + } 44 + 45 + func (t *ToolAdapter) Name() string { 46 + return NamespacedToolName(t.serverName, t.originalName) 47 + } 48 + 49 + func (t *ToolAdapter) Description() string { 50 + return fmt.Sprintf("[mcp:%s] %s", t.serverName, t.description) 51 + } 52 + 53 + func (t *ToolAdapter) ParameterSchema() string { 54 + return t.inputSchema 55 + } 56 + 57 + func (t *ToolAdapter) Execute(ctx context.Context, params map[string]any) (string, error) { 58 + result, err := t.session.CallTool(ctx, &mcp.CallToolParams{ 59 + Name: t.originalName, 60 + Arguments: params, 61 + }) 62 + if err != nil { 63 + return "", fmt.Errorf("mcp tool %q call failed: %w", t.Name(), err) 64 + } 65 + text := formatCallToolResult(result) 66 + if result != nil && result.IsError { 67 + return text, fmt.Errorf("mcp tool %q returned error: %s", t.Name(), text) 68 + } 69 + return text, nil 70 + } 71 + 72 + func formatCallToolResult(result *mcp.CallToolResult) string { 73 + if result == nil { 74 + return "" 75 + } 76 + 77 + var parts []string 78 + for _, c := range result.Content { 79 + switch v := c.(type) { 80 + case *mcp.TextContent: 81 + parts = append(parts, v.Text) 82 + case *mcp.ImageContent: 83 + parts = append(parts, fmt.Sprintf("[image: %s, %d bytes]", v.MIMEType, len(v.Data))) 84 + case *mcp.AudioContent: 85 + parts = append(parts, fmt.Sprintf("[audio: %s, %d bytes]", v.MIMEType, len(v.Data))) 86 + case *mcp.EmbeddedResource: 87 + if v.Resource != nil { 88 + if v.Resource.Text != "" { 89 + parts = append(parts, v.Resource.Text) 90 + } else { 91 + parts = append(parts, fmt.Sprintf("[resource: %s]", v.Resource.URI)) 92 + } 93 + } 94 + default: 95 + b, err := json.Marshal(c) 96 + if err == nil { 97 + parts = append(parts, string(b)) 98 + } 99 + } 100 + } 101 + 102 + return strings.Join(parts, "\n") 103 + }