feat: add --tools flag to filter exposed MCP tools (#167)
Adds `-O`/`-tools` CLI flag and `GITEA_TOOLS` environment variable accepting a comma-separated list of tool names. When set, only the listed tools are exposed to MCP clients, which lets AI agents trim their tool context. Composes with `--read-only`. Unknown names are logged at startup so typos surface instead of failing silently. Co-Authored-By: silverwind <me@silverwind.io> Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
committed by
silverwind
parent
cd82f6f207
commit
bcefbaa9c1
+4
-3
@@ -7,7 +7,8 @@ var (
|
||||
Version string
|
||||
Mode string
|
||||
|
||||
Insecure bool
|
||||
ReadOnly bool
|
||||
Debug bool
|
||||
Insecure bool
|
||||
ReadOnly bool
|
||||
Debug bool
|
||||
AllowedTools map[string]struct{}
|
||||
)
|
||||
|
||||
+47
-7
@@ -1,7 +1,11 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
@@ -27,12 +31,48 @@ func (t *Tool) RegisterRead(s server.ServerTool) {
|
||||
}
|
||||
|
||||
func (t *Tool) Tools() []server.ServerTool {
|
||||
tools := make([]server.ServerTool, 0, len(t.write)+len(t.read))
|
||||
if flag.ReadOnly {
|
||||
tools = append(tools, t.read...)
|
||||
return tools
|
||||
all := make([]server.ServerTool, 0, len(t.write)+len(t.read))
|
||||
if !flag.ReadOnly {
|
||||
all = append(all, t.write...)
|
||||
}
|
||||
tools = append(tools, t.write...)
|
||||
tools = append(tools, t.read...)
|
||||
return tools
|
||||
all = append(all, t.read...)
|
||||
if len(flag.AllowedTools) == 0 {
|
||||
return all
|
||||
}
|
||||
filtered := make([]server.ServerTool, 0, len(all))
|
||||
for _, st := range all {
|
||||
if _, ok := flag.AllowedTools[st.Tool.Name]; ok {
|
||||
filtered = append(filtered, st)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// WarnUnmatchedAllowedTools logs any names in flag.AllowedTools that don't
|
||||
// match a tool registered on any of the given domains. No-op if the allowlist
|
||||
// is empty.
|
||||
func WarnUnmatchedAllowedTools(domains ...*Tool) {
|
||||
if len(flag.AllowedTools) == 0 {
|
||||
return
|
||||
}
|
||||
known := map[string]struct{}{}
|
||||
for _, d := range domains {
|
||||
for _, st := range d.read {
|
||||
known[st.Tool.Name] = struct{}{}
|
||||
}
|
||||
for _, st := range d.write {
|
||||
known[st.Tool.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
var unmatched []string
|
||||
for name := range flag.AllowedTools {
|
||||
if _, ok := known[name]; !ok {
|
||||
unmatched = append(unmatched, name)
|
||||
}
|
||||
}
|
||||
if len(unmatched) == 0 {
|
||||
return
|
||||
}
|
||||
slices.Sort(unmatched)
|
||||
log.Warnf("Unknown tools in --tools allowlist (ignored): %s", strings.Join(unmatched, ", "))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
func makeTool(name string) server.ServerTool {
|
||||
return server.ServerTool{Tool: mcp.NewTool(name)}
|
||||
}
|
||||
|
||||
func names(sts []server.ServerTool) []string {
|
||||
out := make([]string, len(sts))
|
||||
for i, st := range sts {
|
||||
out[i] = st.Tool.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
readOnly bool
|
||||
allowed map[string]struct{}
|
||||
read []string
|
||||
write []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no filters returns write then read",
|
||||
read: []string{"r1", "r2"},
|
||||
write: []string{"w1", "w2"},
|
||||
want: []string{"w1", "w2", "r1", "r2"},
|
||||
},
|
||||
{
|
||||
name: "read-only excludes write",
|
||||
readOnly: true,
|
||||
read: []string{"r1", "r2"},
|
||||
write: []string{"w1"},
|
||||
want: []string{"r1", "r2"},
|
||||
},
|
||||
{
|
||||
name: "allowlist keeps only listed",
|
||||
allowed: map[string]struct{}{"r1": {}, "w1": {}},
|
||||
read: []string{"r1", "r2"},
|
||||
write: []string{"w1", "w2"},
|
||||
want: []string{"w1", "r1"},
|
||||
},
|
||||
{
|
||||
name: "allowlist intersected with read-only drops write entries",
|
||||
readOnly: true,
|
||||
allowed: map[string]struct{}{"r1": {}, "w1": {}},
|
||||
read: []string{"r1", "r2"},
|
||||
write: []string{"w1", "w2"},
|
||||
want: []string{"r1"},
|
||||
},
|
||||
{
|
||||
name: "allowlist with only unknown names returns empty",
|
||||
allowed: map[string]struct{}{"unknown": {}},
|
||||
read: []string{"r1"},
|
||||
write: []string{"w1"},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "empty allowlist map passes through",
|
||||
allowed: map[string]struct{}{},
|
||||
read: []string{"r1"},
|
||||
write: []string{"w1"},
|
||||
want: []string{"w1", "r1"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
origRO, origAllow := flag.ReadOnly, flag.AllowedTools
|
||||
t.Cleanup(func() {
|
||||
flag.ReadOnly, flag.AllowedTools = origRO, origAllow
|
||||
})
|
||||
flag.ReadOnly = tt.readOnly
|
||||
flag.AllowedTools = tt.allowed
|
||||
|
||||
tr := New()
|
||||
for _, n := range tt.read {
|
||||
tr.RegisterRead(makeTool(n))
|
||||
}
|
||||
for _, n := range tt.write {
|
||||
tr.RegisterWrite(makeTool(n))
|
||||
}
|
||||
|
||||
got := names(tr.Tools())
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("Tools() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user