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]

Reply via email to