Skip to content

Commit 0b2b23c

Browse files
committed
🐛 Remove orphaned MCP capability policies #18713
1 parent 9b728c4 commit 0b2b23c

4 files changed

Lines changed: 162 additions & 8 deletions

File tree

kernel/agent/capability.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -169,20 +169,31 @@ func capabilityAllowed(id string, context capabilityAccessContext) bool {
169169
return currentCapabilityAuthorizer.Allows(id, context)
170170
}
171171

172+
func capabilityOwnerAvailable(source, runtime, ownerID string) bool {
173+
if source != "mcp" && runtime != "mcp" {
174+
return true
175+
}
176+
if ownerID == "" {
177+
return true
178+
}
179+
if kernelModel.Conf == nil || kernelModel.Conf.AI == nil || kernelModel.Conf.AI.MCP == nil {
180+
return false
181+
}
182+
for _, server := range kernelModel.Conf.AI.MCP.Servers {
183+
if server.ID == ownerID {
184+
return server.Enabled
185+
}
186+
}
187+
return false
188+
}
189+
172190
func buildCapabilitySet(frontendCapabilities []FrontendCapability, accessContext capabilityAccessContext) (*capabilitySet, error) {
173191
set := &capabilitySet{
174192
registrations: map[string]*capabilityRegistration{},
175193
ids: map[string]*capabilityRegistration{},
176194
}
177195
for _, tool := range tools.GetAllTools() {
178196
id := tools.CapabilityIDForTool(tool)
179-
if id == "" || !capabilityAllowed(id, accessContext) {
180-
continue
181-
}
182-
registered, validator := tools.LookupToolWithValidator(tool.Name)
183-
if registered != tool || validator == nil {
184-
continue
185-
}
186197
runtime := tool.Runtime
187198
if runtime == "" {
188199
runtime = "kernel"
@@ -191,6 +202,14 @@ func buildCapabilitySet(frontendCapabilities []FrontendCapability, accessContext
191202
if source == "" {
192203
source = "native"
193204
}
205+
if id == "" || !capabilityOwnerAvailable(source, runtime, tool.OwnerID) ||
206+
!capabilityAllowed(id, accessContext) {
207+
continue
208+
}
209+
registered, validator := tools.LookupToolWithValidator(tool.Name)
210+
if registered != tool || validator == nil {
211+
continue
212+
}
194213
registration := &capabilityRegistration{
195214
ID: id,
196215
ModelName: tool.Name,
@@ -335,7 +354,12 @@ func capabilityStillExecutable(registration *capabilityRegistration, args map[st
335354
}
336355
accessContext := registration.AccessContext
337356
accessContext.Arguments = args
338-
if !capabilityAllowed(registration.ID, accessContext) {
357+
ownerID := registration.OwnerID
358+
if ownerID == "" && registration.Tool != nil {
359+
ownerID = registration.Tool.OwnerID
360+
}
361+
if !capabilityOwnerAvailable(registration.Source, registration.Runtime, ownerID) ||
362+
!capabilityAllowed(registration.ID, accessContext) {
339363
return false
340364
}
341365
if registration.isBrowser() {

kernel/agent/capability_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,63 @@ func TestCapabilityPolicyControlsExposureAndExecution(t *testing.T) {
108108
}
109109
}
110110

111+
func TestMCPCapabilityRequiresEnabledConfiguredServer(t *testing.T) {
112+
const toolName = "test_mcp_owner_availability"
113+
const serverID = "test-mcp-server"
114+
115+
originalConf := kernelModel.Conf
116+
kernelModel.Conf = kernelModel.NewAppConf()
117+
kernelModel.Conf.AI = conf.NewAI()
118+
t.Cleanup(func() { kernelModel.Conf = originalConf })
119+
120+
tool := &tools.Tool{
121+
Name: toolName,
122+
CapabilityID: tools.BuildCapabilityID("mcp", "backend", serverID, "read"),
123+
Description: "Test MCP owner availability",
124+
InputSchema: tools.ToolSchema{Type: "object"},
125+
Source: "mcp",
126+
OwnerID: serverID,
127+
Runtime: "mcp",
128+
Handler: func(args map[string]any) (tools.CallToolResult, error) {
129+
return tools.CallToolResult{}, nil
130+
},
131+
}
132+
if err := tools.SetTool(toolName, tool); err != nil {
133+
t.Fatal(err)
134+
}
135+
t.Cleanup(func() { tools.RemoveTool(toolName) })
136+
137+
set, err := buildCapabilitySet(nil, capabilityAccessContext{})
138+
if err != nil {
139+
t.Fatal(err)
140+
}
141+
if set.registration(toolName) != nil {
142+
t.Fatal("MCP capability without a configured server was exposed")
143+
}
144+
145+
kernelModel.Conf.AI.MCP.Servers = []conf.MCPServer{{ID: serverID, Enabled: true}}
146+
set, err = buildCapabilitySet(nil, capabilityAccessContext{})
147+
if err != nil {
148+
t.Fatal(err)
149+
}
150+
registration := set.registration(toolName)
151+
if registration == nil || !capabilityStillExecutable(registration, nil) {
152+
t.Fatal("MCP capability for an enabled configured server was unavailable")
153+
}
154+
155+
kernelModel.Conf.AI.MCP.Servers[0].Enabled = false
156+
if capabilityStillExecutable(registration, nil) {
157+
t.Fatal("MCP capability remained executable after its server was disabled")
158+
}
159+
set, err = buildCapabilitySet(nil, capabilityAccessContext{})
160+
if err != nil {
161+
t.Fatal(err)
162+
}
163+
if set.registration(toolName) != nil {
164+
t.Fatal("MCP capability for a disabled server was exposed")
165+
}
166+
}
167+
111168
func TestExplicitCapabilityConfirmationOverridesRiskAndSessionApproval(t *testing.T) {
112169
originalConf := kernelModel.Conf
113170
kernelModel.Conf = kernelModel.NewAppConf()

kernel/conf/ai.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package conf
1919
import (
2020
"encoding/hex"
2121
"encoding/json"
22+
"net/url"
2223
"os"
2324
"strconv"
2425
"strings"
@@ -516,6 +517,7 @@ func (ai *AI) Normalize() {
516517
ai.Agent.MaxRetries = 10
517518
}
518519
}
520+
ai.pruneOrphanedMCPCapabilityPolicies()
519521
if ai.Editing == nil {
520522
ai.Editing = defaultEditing()
521523
} else {
@@ -626,6 +628,32 @@ func (ai *AI) Normalize() {
626628
}
627629
}
628630

631+
func (ai *AI) pruneOrphanedMCPCapabilityPolicies() {
632+
configuredServerIDs := make(map[string]bool, len(ai.MCP.Servers))
633+
for _, server := range ai.MCP.Servers {
634+
configuredServerIDs[url.PathEscape(server.ID)] = true
635+
}
636+
637+
isOrphaned := func(id string) bool {
638+
const prefix = "mcp/backend/"
639+
if !strings.HasPrefix(id, prefix) {
640+
return false
641+
}
642+
serverID, _, ok := strings.Cut(strings.TrimPrefix(id, prefix), "/")
643+
return ok && !configuredServerIDs[serverID]
644+
}
645+
for id := range ai.Agent.CapabilityPolicy.Overrides {
646+
if isOrphaned(id) {
647+
delete(ai.Agent.CapabilityPolicy.Overrides, id)
648+
}
649+
}
650+
for id := range ai.Agent.ApprovalPolicy.Overrides {
651+
if isOrphaned(id) {
652+
delete(ai.Agent.ApprovalPolicy.Overrides, id)
653+
}
654+
}
655+
}
656+
629657
func normalizeApprovalPolicy(policy *ApprovalPolicy) {
630658
// 旧版中的 confirm 表示未自动批准,实际仍按操作风险判断,因此迁移为 risk。
631659
if policy.Default == ApprovalDecisionConfirm ||

kernel/conf/ai_mcp_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,51 @@ func TestNormalizeMCPServerIDs(t *testing.T) {
3535
}
3636
}
3737

38+
func TestNormalizePrunesOrphanedMCPCapabilityPolicies(t *testing.T) {
39+
const retainedID = "mcp/backend/retained-server/read"
40+
const similarID = "mcp/backend/retained-server-similar/read"
41+
const orphanedID = "mcp/backend/removed-server/read"
42+
const nativeID = "native/backend/search"
43+
44+
ai := NewAI()
45+
ai.MCP.Servers = []MCPServer{{ID: "retained-server", Name: "retained", Enabled: false}}
46+
ai.Agent.CapabilityPolicy.Overrides = map[string]string{
47+
retainedID: "deny",
48+
similarID: "deny",
49+
orphanedID: "deny",
50+
nativeID: "deny",
51+
}
52+
ai.Agent.ApprovalPolicy.Overrides = map[string]*CapabilityApproval{
53+
retainedID: {Default: ApprovalDecisionAllow},
54+
similarID: {Default: ApprovalDecisionAllow},
55+
orphanedID: {Actions: map[string]string{"read": ApprovalDecisionConfirm}},
56+
nativeID: {Default: ApprovalDecisionAllow},
57+
}
58+
59+
ai.Normalize()
60+
61+
if _, exists := ai.Agent.CapabilityPolicy.Overrides[orphanedID]; exists {
62+
t.Fatal("orphaned MCP capability policy was not pruned")
63+
}
64+
if _, exists := ai.Agent.ApprovalPolicy.Overrides[orphanedID]; exists {
65+
t.Fatal("orphaned MCP approval policy was not pruned")
66+
}
67+
for _, id := range []string{retainedID, nativeID} {
68+
if _, exists := ai.Agent.CapabilityPolicy.Overrides[id]; !exists {
69+
t.Fatalf("configured capability policy was pruned: %s", id)
70+
}
71+
if _, exists := ai.Agent.ApprovalPolicy.Overrides[id]; !exists {
72+
t.Fatalf("configured approval policy was pruned: %s", id)
73+
}
74+
}
75+
if _, exists := ai.Agent.CapabilityPolicy.Overrides[similarID]; exists {
76+
t.Fatal("capability policy for a similarly named orphaned server was not pruned")
77+
}
78+
if _, exists := ai.Agent.ApprovalPolicy.Overrides[similarID]; exists {
79+
t.Fatal("approval policy for a similarly named orphaned server was not pruned")
80+
}
81+
}
82+
3883
func TestMigrateMCPEnvironment(t *testing.T) {
3984
mcp := migrateMCP(map[string]any{
4085
"servers": []any{

0 commit comments

Comments
 (0)