fix(mcp): unified grant revocation with per-agent tool group isolation

- Add GrantChecker interface with cache + event-bus invalidation for revalidating MCP tool grants at execution time (not just dispatch)
- Move toolGroups from package-level global to per-Registry field; Clone() gives each agent Loop isolated tool groups, eliminating cross-agent race condition
- BridgeTool.Execute rechecks grant validity before executing MCP methods; uses serverID field for cache key
- Wire MCPGrantChecker through ResolverDeps to MCP Manager and LoopConfig
- Add integration test for grant revocation scenarios
- Update policy functions to accept *Registry parameter
This commit is contained in:
viettranx committed 2026-04-16 17:07:56 +07:00
1 parent 8618ed94b2
commit 8b8da3a3dd
20 files changed
+1128 -150

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
//go:build integration
package integration
import (
"context"
"database/sql"
"sync/atomic"
"testing"
mcpclient "github.com/mark3labs/mcp-go/client"
mcpgo "github.com/mark3labs/mcp-go/mcp"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// TestBridgeTool_Execute_RevokeAgentGrant_ReturnsError verifies that after revoking
// an agent grant, BridgeTool.Execute returns an error instead of executing the tool.
//
// This test MUST FAIL initially (Phase 01 TDD) because BridgeTool.Execute currently
// only checks `connected` status — it does NOT recheck grants.
func TestBridgeTool_Execute_RevokeAgentGrant_ReturnsError(t *testing.T) {
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
serverID := seedMCPServer(t, db, tenantID)
// Grant agent access to the MCP server
grantAgentAccess(t, db, tenantID, serverID, agentID)
// Create MCP store
mcpStore := pg.NewPGMCPServerStore(db, testEncryptionKey)
ctx := store.WithTenantID(context.Background(), tenantID)
ctx = store.WithAgentID(ctx, agentID)
ctx = store.WithUserID(ctx, "test-user")
// Verify grant is active
accessible, err := mcpStore.ListAccessible(ctx, agentID, "test-user")
if err != nil {
t.Fatalf("ListAccessible: %v", err)
}
if len(accessible) == 0 {
t.Fatal("expected at least 1 accessible server after grant")
}
// Create a fake MCP client that returns a stub result
fakeClient := &fakeMCPClient{result: &mcpgo.CallToolResult{
Content: []mcpgo.Content{mcpgo.TextContent{Text: "success"}},
}}
// Create BridgeTool with the fake client
clientPtr := &atomic.Pointer[mcpclient.Client]{}
// Note: We need to cast the fake client to the interface type
// This is a workaround since mcp-go client is a struct, not an interface
connected := &atomic.Bool{}
connected.Store(true)
// Create a grant checker that checks the store
grantChecker := mcp.NewStoreGrantChecker(mcpStore, nil)
tool := mcp.NewBridgeTool(
"test-server",
mcpgo.Tool{Name: "test_tool", Description: "test"},
clientPtr,
"mcp_test",
60,
connected,
serverID,
grantChecker,
)
// Execute should work before revoke (will fail due to nil client, but that's expected)
// The key point is: after revoke, it should return "grant revoked" error
// Now revoke the agent grant
err = mcpStore.RevokeFromAgent(ctx, serverID, agentID)
if err != nil {
t.Fatalf("RevokeFromAgent: %v", err)
}
// Execute the tool after revoke
// EXPECTED (after Phase 02 fix): should return ErrorResult with "grant revoked"
// ACTUAL (currently): will try to execute and fail with "no active client" or succeed
result := tool.Execute(ctx, map[string]any{"arg": "value"})
// This assertion SHOULD PASS after Phase 02, but FAILS now
// because BridgeTool.Execute does NOT recheck grants
if !result.IsError {
t.Error("expected error result after grant revoked, but got success")
}
if result.IsError && !containsGrantRevoked(result.ForLLM) {
t.Errorf("expected 'grant revoked' error, got: %s", result.ForLLM)
}
}
// TestBridgeTool_Execute_RevokeUserGrant_ReturnsError verifies that after revoking
// a user grant, BridgeTool.Execute returns an error.
//
// This test MUST FAIL initially (Phase 01 TDD).
func TestBridgeTool_Execute_RevokeUserGrant_ReturnsError(t *testing.T) {
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
serverID := seedMCPServer(t, db, tenantID)
userID := "test-user-" + uuid.New().String()[:8]
// Grant agent access (required for ListAccessible)
grantAgentAccess(t, db, tenantID, serverID, agentID)
// Grant user access
grantUserAccess(t, db, tenantID, serverID, userID)
// Create MCP store
mcpStore := pg.NewPGMCPServerStore(db, testEncryptionKey)
ctx := store.WithTenantID(context.Background(), tenantID)
ctx = store.WithAgentID(ctx, agentID)
ctx = store.WithUserID(ctx, userID)
// Verify both grants are active
accessible, err := mcpStore.ListAccessible(ctx, agentID, userID)
if err != nil {
t.Fatalf("ListAccessible: %v", err)
}
if len(accessible) == 0 {
t.Fatal("expected accessible server after grants")
}
// Create BridgeTool
clientPtr := &atomic.Pointer[mcpclient.Client]{}
connected := &atomic.Bool{}
connected.Store(true)
// Create a grant checker that checks the store
grantChecker := mcp.NewStoreGrantChecker(mcpStore, nil)
tool := mcp.NewBridgeTool(
"test-server",
mcpgo.Tool{Name: "test_tool", Description: "test"},
clientPtr,
"mcp_test",
60,
connected,
serverID,
grantChecker,
)
// Revoke the USER grant (agent grant still active)
err = mcpStore.RevokeFromUser(ctx, serverID, userID)
if err != nil {
t.Fatalf("RevokeFromUser: %v", err)
}
// Execute the tool after user revoke
// EXPECTED (after Phase 02 fix): should return "grant revoked" since user lost access
// ACTUAL (currently): does not check user grants at execute time
result := tool.Execute(ctx, map[string]any{"arg": "value"})
// This assertion SHOULD PASS after Phase 02, but FAILS now
if !result.IsError {
t.Error("expected error result after user grant revoked")
}
if result.IsError && !containsGrantRevoked(result.ForLLM) {
t.Errorf("expected 'grant revoked' error, got: %s", result.ForLLM)
}
}
// TestResolver_Rebuild_AfterRevoke_NoToolInPrompt verifies that after revoking a grant,
// the next resolver.Get() returns a Loop without the revoked tool in the prompt.
//
// This test SHOULD PASS even before fixes (regression guard) because the existing
// unregisterAllTools + fresh clone mechanism already handles prompt rebuild.
func TestResolver_Rebuild_AfterRevoke_NoToolInPrompt(t *testing.T) {
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
serverID := seedMCPServer(t, db, tenantID)
// Grant agent access
grantAgentAccess(t, db, tenantID, serverID, agentID)
// Create MCP store
mcpStore := pg.NewPGMCPServerStore(db, testEncryptionKey)
ctx := store.WithTenantID(context.Background(), tenantID)
// Verify grant is active
accessible, err := mcpStore.ListAccessible(ctx, agentID, "test-user")
if err != nil {
t.Fatalf("ListAccessible before revoke: %v", err)
}
if len(accessible) == 0 {
t.Fatal("expected accessible server before revoke")
}
serverName := accessible[0].Server.Name
// Revoke the grant
err = mcpStore.RevokeFromAgent(ctx, serverID, agentID)
if err != nil {
t.Fatalf("RevokeFromAgent: %v", err)
}
// Verify no servers accessible after revoke
accessible, err = mcpStore.ListAccessible(ctx, agentID, "test-user")
if err != nil {
t.Fatalf("ListAccessible after revoke: %v", err)
}
if len(accessible) != 0 {
t.Errorf("expected 0 accessible servers after revoke, got %d", len(accessible))
}
// This test passes as a regression guard:
// The next LoadForAgent() will query ListAccessible which returns empty,
// so no MCP tools will be registered. The prompt rebuild mechanism works.
t.Logf("Regression guard PASS: server %q no longer accessible after revoke", serverName)
}
// --- Helpers ---
func grantAgentAccess(t *testing.T, db *sql.DB, tenantID, serverID, agentID uuid.UUID) {
t.Helper()
_, err := db.Exec(
`INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, granted_by, created_at, tenant_id)
VALUES ($1, $2, $3, true, 'test-admin', NOW(), $4)
ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = true`,
uuid.New(), serverID, agentID, tenantID)
if err != nil {
t.Fatalf("grantAgentAccess: %v", err)
}
}
func grantUserAccess(t *testing.T, db *sql.DB, tenantID, serverID uuid.UUID, userID string) {
t.Helper()
_, err := db.Exec(
`INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, granted_by, created_at, tenant_id)
VALUES ($1, $2, $3, true, 'test-admin', NOW(), $4)
ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = true`,
uuid.New(), serverID, userID, tenantID)
if err != nil {
t.Fatalf("grantUserAccess: %v", err)
}
}
func containsGrantRevoked(s string) bool {
return len(s) > 0 && (contains(s, "grant revoked") || contains(s, "grant denied"))
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// fakeMCPClient is a stub for testing. Since mcpclient.Client is a struct
// and not an interface, we cannot directly mock it. The test relies on
// the clientPtr being nil or the connection being marked as disconnected.
type fakeMCPClient struct {
result *mcpgo.CallToolResult
err error
}