Skip to content

Commit 86664c8

Browse files
committed
Filter direct tools by agent scope
1 parent 5fb0cc2 commit 86664c8

4 files changed

Lines changed: 201 additions & 0 deletions

File tree

internal/server/mcp.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"regexp"
99
"strconv"
1010
"strings"
11+
"sync"
1112
"sync/atomic"
1213
"time"
1314

@@ -109,6 +110,13 @@ type MCPProxyServer struct {
109110
// Hooks shared across all routing mode servers
110111
hooks *mcpserver.Hooks
111112

113+
// directToolPerms maps direct-mode tool names (server__tool) to the
114+
// operation permission required to call them. It is populated with the
115+
// direct-mode registry and used only to filter tools/list for scoped agent
116+
// tokens; execution-time authorization remains authoritative.
117+
directToolPermsMu sync.RWMutex
118+
directToolPerms map[string]string
119+
112120
// Spec 049: in-memory only counter of retrieve_tools calls that opted into
113121
// include_disabled. Never persisted (privacy, consistent with Spec 042).
114122
includeDisabledCalls atomic.Int64
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package server
2+
3+
import (
4+
"context"
5+
6+
"github.com/mark3labs/mcp-go/mcp"
7+
8+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/auth"
9+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
10+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
11+
)
12+
13+
func requiredPermissionForDirectTool(annotations *config.ToolAnnotations) string {
14+
requiredVariant := contracts.DeriveCallWith(annotations)
15+
requiredPerm := contracts.ToolVariantToOperationType[requiredVariant]
16+
if requiredPerm == "" {
17+
return contracts.OperationTypeRead
18+
}
19+
return requiredPerm
20+
}
21+
22+
func (p *MCPProxyServer) setDirectToolPermissions(perms map[string]string) {
23+
p.directToolPermsMu.Lock()
24+
defer p.directToolPermsMu.Unlock()
25+
26+
if len(perms) == 0 {
27+
p.directToolPerms = nil
28+
return
29+
}
30+
31+
p.directToolPerms = perms
32+
}
33+
34+
func (p *MCPProxyServer) lookupDirectToolPermission(directName string) (string, bool) {
35+
p.directToolPermsMu.RLock()
36+
defer p.directToolPermsMu.RUnlock()
37+
38+
perm, ok := p.directToolPerms[directName]
39+
return perm, ok
40+
}
41+
42+
// filterDirectModeToolsForAuth filters tools/list for scoped agent tokens.
43+
//
44+
// Direct mode registers upstream tools globally as server__tool. Without this
45+
// filter, scoped agent tokens prevent execution but still disclose tool names,
46+
// descriptions, and schemas for servers outside their scope. Call-time auth is
47+
// still authoritative; this filter only removes tools that the current token
48+
// could not call from discovery responses.
49+
func (p *MCPProxyServer) filterDirectModeToolsForAuth(ctx context.Context, tools []mcp.Tool) []mcp.Tool {
50+
if len(tools) == 0 {
51+
return tools
52+
}
53+
54+
authCtx := auth.AuthContextFromContext(ctx)
55+
if authCtx == nil || authCtx.Type != auth.AuthTypeAgent {
56+
return tools
57+
}
58+
59+
filtered := tools[:0]
60+
for _, tool := range tools {
61+
serverName, _, ok := ParseDirectToolName(tool.Name)
62+
if !ok {
63+
filtered = append(filtered, tool)
64+
continue
65+
}
66+
67+
if !authCtx.CanAccessServer(serverName) {
68+
continue
69+
}
70+
71+
requiredPerm, ok := p.lookupDirectToolPermission(tool.Name)
72+
if !ok {
73+
continue
74+
}
75+
76+
if requiredPerm != "" && !authCtx.HasPermission(requiredPerm) {
77+
continue
78+
}
79+
80+
filtered = append(filtered, tool)
81+
}
82+
83+
return filtered
84+
}

internal/server/mcp_routing.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,16 @@ func (p *MCPProxyServer) buildDirectModeTools() []mcpserver.ServerTool {
4848
// Use DiscoverTools which already filters for connected, enabled, non-quarantined servers
4949
tools, err := p.upstreamManager.DiscoverTools(ctx)
5050
if err != nil {
51+
p.setDirectToolPermissions(nil)
5152
p.logger.Error("failed to discover tools for direct mode", zap.Error(err))
5253
return nil
5354
}
5455

5556
serverTools := make([]mcpserver.ServerTool, 0, len(tools))
57+
directToolPerms := make(map[string]string, len(tools))
5658
for _, tool := range tools {
5759
directName := FormatDirectToolName(tool.ServerName, tool.Name)
60+
directToolPerms[directName] = requiredPermissionForDirectTool(tool.Annotations)
5861

5962
// Build MCP tool options
6063
opts := []mcp.ToolOption{
@@ -110,6 +113,8 @@ func (p *MCPProxyServer) buildDirectModeTools() []mcpserver.ServerTool {
110113
})
111114
}
112115

116+
p.setDirectToolPermissions(directToolPerms)
117+
113118
p.logger.Info("built direct mode tools",
114119
zap.Int("tool_count", len(serverTools)))
115120

@@ -483,6 +488,7 @@ func (p *MCPProxyServer) initRoutingModeServers() {
483488
opts := []mcpserver.ServerOption{
484489
mcpserver.WithToolCapabilities(true),
485490
mcpserver.WithRecovery(),
491+
mcpserver.WithToolFilter(p.filterDirectModeToolsForAuth),
486492
}
487493
if p.hooks != nil {
488494
opts = append(opts, mcpserver.WithHooks(p.hooks))

internal/server/mcp_routing_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,109 @@ func TestDirectModeHandler_DestructiveToolNeedsDestructivePermission(t *testing.
366366
assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "destructive")
367367
}
368368

369+
func TestFilterDirectModeToolsForAuth_AgentServerAndPermissionScope(t *testing.T) {
370+
proxy := &MCPProxyServer{}
371+
372+
githubRead := FormatDirectToolName("github", "get_issue")
373+
githubWrite := FormatDirectToolName("github", "create_issue")
374+
githubDestroy := FormatDirectToolName("github", "delete_repo")
375+
gitlabRead := FormatDirectToolName("gitlab", "get_issue")
376+
377+
proxy.setDirectToolPermissions(map[string]string{
378+
githubRead: auth.PermRead,
379+
githubWrite: auth.PermWrite,
380+
githubDestroy: auth.PermDestructive,
381+
gitlabRead: auth.PermRead,
382+
})
383+
384+
tools := []mcp.Tool{
385+
{Name: githubRead},
386+
{Name: githubWrite},
387+
{Name: githubDestroy},
388+
{Name: gitlabRead},
389+
}
390+
391+
agentCtx := auth.WithAuthContext(context.Background(), &auth.AuthContext{
392+
Type: auth.AuthTypeAgent,
393+
AgentName: "test-agent",
394+
AllowedServers: []string{"github"},
395+
Permissions: []string{auth.PermRead, auth.PermWrite},
396+
})
397+
398+
filtered := proxy.filterDirectModeToolsForAuth(agentCtx, tools)
399+
400+
assert.Equal(t, []string{githubRead, githubWrite}, directToolNamesForTest(filtered))
401+
}
402+
403+
func TestFilterDirectModeToolsForAuth_NonAgentUnchanged(t *testing.T) {
404+
proxy := &MCPProxyServer{}
405+
tools := []mcp.Tool{
406+
{Name: FormatDirectToolName("github", "get_issue")},
407+
{Name: FormatDirectToolName("gitlab", "get_issue")},
408+
}
409+
410+
assert.Equal(t, tools, proxy.filterDirectModeToolsForAuth(context.Background(), tools))
411+
412+
adminCtx := auth.WithAuthContext(context.Background(), auth.AdminContext())
413+
assert.Equal(t, tools, proxy.filterDirectModeToolsForAuth(adminCtx, tools))
414+
}
415+
416+
func TestFilterDirectModeToolsForAuth_FailsClosedOnMissingPermissionMetadata(t *testing.T) {
417+
proxy := &MCPProxyServer{}
418+
419+
visible := FormatDirectToolName("github", "get_issue")
420+
missing := FormatDirectToolName("github", "unknown")
421+
proxy.setDirectToolPermissions(map[string]string{
422+
visible: auth.PermRead,
423+
})
424+
425+
ctx := auth.WithAuthContext(context.Background(), &auth.AuthContext{
426+
Type: auth.AuthTypeAgent,
427+
AgentName: "test-agent",
428+
AllowedServers: []string{"github"},
429+
Permissions: []string{auth.PermRead},
430+
})
431+
432+
filtered := proxy.filterDirectModeToolsForAuth(ctx, []mcp.Tool{
433+
{Name: visible},
434+
{Name: missing},
435+
})
436+
437+
assert.Equal(t, []string{visible}, directToolNamesForTest(filtered))
438+
}
439+
440+
func TestFilterDirectModeToolsForAuth_KeepsNonDirectTools(t *testing.T) {
441+
proxy := &MCPProxyServer{}
442+
443+
direct := FormatDirectToolName("github", "get_issue")
444+
nonDirect := "retrieve_tools"
445+
proxy.setDirectToolPermissions(map[string]string{
446+
direct: auth.PermRead,
447+
})
448+
449+
ctx := auth.WithAuthContext(context.Background(), &auth.AuthContext{
450+
Type: auth.AuthTypeAgent,
451+
AgentName: "test-agent",
452+
AllowedServers: []string{"github"},
453+
Permissions: []string{auth.PermRead},
454+
})
455+
456+
filtered := proxy.filterDirectModeToolsForAuth(ctx, []mcp.Tool{
457+
{Name: direct},
458+
{Name: nonDirect},
459+
})
460+
461+
assert.Equal(t, []string{direct, nonDirect}, directToolNamesForTest(filtered))
462+
}
463+
464+
func directToolNamesForTest(tools []mcp.Tool) []string {
465+
names := make([]string, 0, len(tools))
466+
for _, tool := range tools {
467+
names = append(names, tool.Name)
468+
}
469+
return names
470+
}
471+
369472
func TestDirectModeHandler_NoAuthContext(t *testing.T) {
370473
logger, _ := zap.NewDevelopment()
371474
proxy := &MCPProxyServer{

0 commit comments

Comments
 (0)