on main help sign in
feat(tools): create an allow list for MCP tools (#2800)
commit959bf7e289622a0870ae6894a9e9d07d7b9d64d7 authorBruno Krugel <brunokrugel@gmail.com> dateTue 12 May 2026, 14:49 (5 months ago) parentb223e24
4 files changed+83−11
internal/agent/tools/mcp/tools.go+24−11 internal/agent/tools/mcp/tools_test.go+48 internal/config/config.go+1 schema.json+10
internal/agent/tools/mcp/tools.go+24−11
@@ -142,7 +142,10 @@ func getTools(ctx context.Context, session *ClientSession) ([]*Tool, error) {
142142}
143143
144144func updateTools(cfg *config.ConfigStore, name string, tools []*Tool) int {
145− tools = filterDisabledTools(cfg, name, tools)
145+ mcpCfg, ok := cfg.Config().MCP[name]
146+ if ok {
147+ tools = filterTools(mcpCfg, tools)
148+ }
146149 if len(tools) == 0 {
147150 allTools.Del(name)
148151 return 0
@@ -151,20 +154,30 @@ func updateTools(cfg *config.ConfigStore, name string, tools []*Tool) int {
151154 return len(tools)
152155}
153156
154−// filterDisabledTools removes tools that are disabled via config.
155−func filterDisabledTools(cfg *config.ConfigStore, mcpName string, tools []*Tool) []*Tool {
156− mcpCfg, ok := cfg.Config().MCP[mcpName]
157− if !ok || len(mcpCfg.DisabledTools) == 0 {
158− return tools
157+// filterTools filters tools based on enabled_tools (allow list) and
158+// disabled_tools (deny list) from the MCP config.
159+func filterTools(mcpCfg config.MCPConfig, tools []*Tool) []*Tool {
160+ if len(mcpCfg.EnabledTools) > 0 {
161+ filtered := make([]*Tool, 0, len(mcpCfg.EnabledTools))
162+ for _, tool := range tools {
163+ if slices.Contains(mcpCfg.EnabledTools, tool.Name) {
164+ filtered = append(filtered, tool)
165+ }
166+ }
167+ tools = filtered
159168 }
160169
161− filtered := make([]*Tool, 0, len(tools))
162− for _, tool := range tools {
163− if !slices.Contains(mcpCfg.DisabledTools, tool.Name) {
164− filtered = append(filtered, tool)
170+ if len(mcpCfg.DisabledTools) > 0 {
171+ filtered := make([]*Tool, 0, len(tools))
172+ for _, tool := range tools {
173+ if !slices.Contains(mcpCfg.DisabledTools, tool.Name) {
174+ filtered = append(filtered, tool)
175+ }
165176 }
177+ tools = filtered
166178 }
167− return filtered
179+
180+ return tools
168181}
169182
170183// ensureRawBytes normalizes MCP media data into raw binary bytes.
internal/agent/tools/mcp/tools_test.go+48
@@ -5,6 +5,7 @@ import (
55 "encoding/base64"
66 "testing"
77
8+ "github.com/charmbracelet/crush/internal/config"
89 "github.com/stretchr/testify/require"
910)
1011
@@ -67,3 +68,50 @@ func TestEnsureRawBytes(t *testing.T) {
6768 })
6869 }
6970}
71+
72+func TestFilterTools(t *testing.T) {
73+ t.Parallel()
74+
75+ tools := []*Tool{
76+ {Name: "tool_a"},
77+ {Name: "tool_b"},
78+ {Name: "tool_c"},
79+ }
80+
81+ t.Run("no filters returns all tools", func(t *testing.T) {
82+ t.Parallel()
83+ result := filterTools(config.MCPConfig{}, tools)
84+ require.Len(t, result, 3)
85+ })
86+
87+ t.Run("disabled tools filters deny list", func(t *testing.T) {
88+ t.Parallel()
89+ result := filterTools(config.MCPConfig{DisabledTools: []string{"tool_a"}}, tools)
90+ require.Len(t, result, 2)
91+ require.Equal(t, "tool_b", result[0].Name)
92+ require.Equal(t, "tool_c", result[1].Name)
93+ })
94+
95+ t.Run("enabled tools acts as allow list", func(t *testing.T) {
96+ t.Parallel()
97+ result := filterTools(config.MCPConfig{EnabledTools: []string{"tool_b"}}, tools)
98+ require.Len(t, result, 1)
99+ require.Equal(t, "tool_b", result[0].Name)
100+ })
101+
102+ t.Run("enabled and disabled both apply", func(t *testing.T) {
103+ t.Parallel()
104+ result := filterTools(config.MCPConfig{
105+ EnabledTools: []string{"tool_a", "tool_b"},
106+ DisabledTools: []string{"tool_b"},
107+ }, tools)
108+ require.Len(t, result, 1)
109+ require.Equal(t, "tool_a", result[0].Name)
110+ })
111+
112+ t.Run("enabled with non-existent tool returns empty", func(t *testing.T) {
113+ t.Parallel()
114+ result := filterTools(config.MCPConfig{EnabledTools: []string{"non_existent"}}, tools)
115+ require.Len(t, result, 0)
116+ })
117+}
internal/config/config.go+1
@@ -188,6 +188,7 @@ type MCPConfig struct {
188188 URL string `json:"url,omitempty" jsonschema:"description=URL for HTTP or SSE MCP servers,format=uri,example=http://localhost:3000/mcp"`
189189 Disabled bool `json:"disabled,omitempty" jsonschema:"description=Whether this MCP server is disabled,default=false"`
190190 DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of tools from this MCP server to disable,example=get-library-doc"`
191+ EnabledTools []string `json:"enabled_tools,omitempty" jsonschema:"description=Allow list of tools from this MCP server,example=get-library-doc"`
191192 Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for MCP server connections,default=15,example=30,example=60,example=120"`
192193
193194 // Headers are HTTP headers for HTTP/SSE MCP servers. Values run
schema.json+10
@@ -264,6 +264,16 @@
264264 "type": "array",
265265 "description": "List of tools from this MCP server to disable"
266266 },
267+ "enabled_tools": {
268+ "items": {
269+ "type": "string",
270+ "examples": [
271+ "get-library-doc"
272+ ]
273+ },
274+ "type": "array",
275+ "description": "Allow list of tools from this MCP server"
276+ },
267277 "timeout": {
268278 "type": "integer",
269279 "description": "Timeout in seconds for MCP server connections",