This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 7dc31f0beeb9cd82bfc76430890b035d33b8609b Author: Yang Luo <[email protected]> AuthorDate: Sat Sep 5 19:24:56 2026 +0800 feat(permissions): give each tool of a tested connection its own switch --- controllers/permission.go | 3 +++ object/connection.go | 27 ++++++++++++++++++++++++ object/permission_casbin.go | 22 +++++++++++++++++++- object/permission_tools.go | 39 ++++++++++++++++++++++++++++++++--- web/src/components/PermissionCard.tsx | 16 +++++++++++++- 5 files changed, 102 insertions(+), 5 deletions(-) diff --git a/controllers/permission.go b/controllers/permission.go index 1ee8bfd..2784dfd 100644 --- a/controllers/permission.go +++ b/controllers/permission.go @@ -57,6 +57,9 @@ func mcpItemsOf(agentId string, owner string) []object.ToolItem { for _, server := range inventory.McpServers { items = append(items, object.McpServerItem(server.Name)) } + // A connection that has been tested knows what it offers, so its tools get a + // switch each, under the server switch they already had. + items = append(items, object.ConnectionToolItems(agentId, owner)...) return items } diff --git a/object/connection.go b/object/connection.go index 408c156..c0ede83 100644 --- a/object/connection.go +++ b/object/connection.go @@ -17,6 +17,7 @@ package object import ( "errors" "fmt" + "slices" "sort" "github.com/apache/casbin-gateway/connector" @@ -235,3 +236,29 @@ func DeleteConnection(owner string, name string) error { _, err := ormer.Engine.ID(core.PK{owner, name}).Delete(&Connection{}) return err } + +// ConnectionToolItems is one switch per tool of every connection installed in +// this agent, built from what the last test found. A connection nobody has +// tested contributes none and is governed by its server switch alone, which is +// what makes testing worth doing beyond seeing that it works. +func ConnectionToolItems(agentId string, owner string) []ToolItem { + connections, err := GetConnections(owner) + if err != nil { + return nil + } + + items := []ToolItem{} + for _, connection := range connections { + if len(connection.Tools) == 0 || !slices.Contains(connection.Agents, agentId) { + continue + } + found, ok := connector.Get(connection.Name) + if !ok { + continue + } + for _, tool := range connection.Tools { + items = append(items, McpToolItem(found.Server.Name, tool.Name)) + } + } + return items +} diff --git a/object/permission_casbin.go b/object/permission_casbin.go index ee6a032..caed617 100644 --- a/object/permission_casbin.go +++ b/object/permission_casbin.go @@ -69,6 +69,10 @@ const ( type AgentGuard struct { agentId string enforcer *casbin.Enforcer + // switched is every item this permission actually has a switch for. It is + // what lets one tool of an MCP server be decided by its own switch rather + // than by the server's, without a rule for every tool nobody has set. + switched map[string]bool } var ( @@ -99,7 +103,7 @@ func LoadAgentGuard(agentId string) (*AgentGuard, error) { return nil, err } - guard := &AgentGuard{agentId: agentId, enforcer: enforcer} + guard := &AgentGuard{agentId: agentId, enforcer: enforcer, switched: permission.switchedItems()} guardCache[agentId] = guard return guard, nil } @@ -209,6 +213,15 @@ func (permission *AgentPermission) appendToolRules(add func(object string, effec // listed reports whether a switch was ever set. An item nobody has touched is // allowed, so a switch added in a later version does not silently take // something away from an agent configured before it existed. +// switchedItems is every item somebody has set a switch for. +func (permission *AgentPermission) switchedItems() map[string]bool { + switched := map[string]bool{} + for name := range permission.Tools { + switched[name] = true + } + return switched +} + func (permission *AgentPermission) listed(name string) bool { _, found := permission.Tools[name] return found @@ -342,6 +355,13 @@ func (guard *AgentGuard) AllowProvider(providerId string) bool { // AllowTool reports whether this agent may be offered one tool, by name. func (guard *AgentGuard) AllowTool(name string) bool { + // A tool of an MCP server may have a switch of its own, set from the tools + // a connection was found to offer. That switch decides it; the server's own + // switch is what every other tool of that server falls back to. + if specific, ok := McpToolItemOf(name); ok && guard.switched[specific] { + return guard.allow(objectTool + specific) + } + entry := ToolItemOf(name) if entry == "" { return true diff --git a/object/permission_tools.go b/object/permission_tools.go index d4d46c8..529aec5 100644 --- a/object/permission_tools.go +++ b/object/permission_tools.go @@ -159,6 +159,31 @@ func McpServerItem(server string) ToolItem { } } +// McpToolItem is the switch for one tool of one MCP server, which exists only +// once a connection has been tested and said what it offers. It sits under that +// server's own switch and takes precedence over it, so a server can be left on +// with one of its tools taken away, or off with one of them left. +func McpToolItem(server string, tool string) ToolItem { + return ToolItem{ + Name: GroupMcp + "/" + normalizeToolName(strings.ToLower(server)) + "/" + normalizeToolName(strings.ToLower(tool)), + Group: GroupMcp, + Label: server + " ยท " + tool, + Tools: []string{"mcp__" + server + "__" + tool}, + } +} + +// McpToolItemOf names the switch one MCP tool would have of its own, which is +// not the same as there being one: the caller decides what to do when nobody +// has set it. +func McpToolItemOf(name string) (string, bool) { + lower := strings.ToLower(strings.TrimSpace(name)) + server, tool, ok := mcpPartsOf(lower) + if !ok || server == "" || tool == "" { + return "", false + } + return GroupMcp + "/" + server + "/" + tool, true +} + // CatalogItemNames is every switch of the built-in catalogue, which is what a // stored permission is read against. func CatalogItemNames() []string { @@ -236,6 +261,14 @@ func ToolItemOf(name string) string { // mcpServerOf reads the server out of an MCP tool name, which the agents write // as "mcp__<server>__<tool>". func mcpServerOf(lower string) (string, bool) { + server, _, ok := mcpPartsOf(lower) + return server, ok +} + +// mcpPartsOf splits an MCP tool name into the server and the tool. A name that +// carries no tool part yields an empty one rather than a failure: it is still +// that server's, which is what the server's own switch is for. +func mcpPartsOf(lower string) (string, string, bool) { for _, prefix := range []string{"mcp__", "mcp_", "mcp."} { if !strings.HasPrefix(lower, prefix) { continue @@ -243,12 +276,12 @@ func mcpServerOf(lower string) (string, bool) { rest := strings.TrimPrefix(lower, prefix) for _, separator := range []string{"__", "_", "."} { if index := strings.Index(rest, separator); index > 0 { - return normalizeToolName(rest[:index]), true + return normalizeToolName(rest[:index]), normalizeToolName(rest[index:]), true } } - return normalizeToolName(rest), true + return normalizeToolName(rest), "", true } - return "", false + return "", "", false } // normalizeToolName drops what only separates the words of a tool's name, so diff --git a/web/src/components/PermissionCard.tsx b/web/src/components/PermissionCard.tsx index a8b778a..53d6c6d 100644 --- a/web/src/components/PermissionCard.tsx +++ b/web/src/components/PermissionCard.tsx @@ -192,7 +192,12 @@ function GroupBlock({ {items.map(item => ( <label key={item.name} - className="flex items-start justify-between gap-3 border-b py-2 text-sm last:border-b-0 sm:[&:nth-last-child(2)]:border-b-0" + className={cn( + "flex items-start justify-between gap-3 border-b py-2 text-sm last:border-b-0 sm:[&:nth-last-child(2)]:border-b-0", + // One tool of an MCP server sits under that server's own switch, + // and reads as belonging to it rather than beside it. + isNestedItem(item) && "pl-4", + )} > <span className="min-w-0"> {itemLabel(item)} @@ -216,6 +221,15 @@ function GroupBlock({ ); } +/** + * An item that belongs under another one. Only the MCP group has these: a + * server is "mcp/<server>" and one of its tools is "mcp/<server>/<tool>", so + * the extra segment is what marks it as nested. + */ +function isNestedItem(item: ToolItem) { + return item.name.split("/").length > 2; +} + /** The casbin model and policy the switches above compile to, which is what is * actually enforced, plus the rules that are only written here. */ function AdvancedView({ --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
