Skip to content

Commit 79a0ac0

Browse files
authored
Add RemoveResource method to MCPServer (#141)
1 parent 0448984 commit 79a0ac0

File tree

2 files changed

+265
-0
lines changed

2 files changed

+265
-0
lines changed

server/resource_test.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/mark3labs/mcp-go/mcp"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestMCPServer_RemoveResource(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
action func(*testing.T, *MCPServer, chan mcp.JSONRPCNotification)
17+
expectedNotifications int
18+
validate func(*testing.T, []mcp.JSONRPCNotification, mcp.JSONRPCMessage)
19+
}{
20+
{
21+
name: "RemoveResource removes the resource from the server",
22+
action: func(t *testing.T, server *MCPServer, notificationChannel chan mcp.JSONRPCNotification) {
23+
// Add a test resource
24+
server.AddResource(
25+
mcp.NewResource(
26+
"test://resource1",
27+
"Resource 1",
28+
mcp.WithResourceDescription("Test resource 1"),
29+
mcp.WithMIMEType("text/plain"),
30+
),
31+
func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
32+
return []mcp.ResourceContents{
33+
mcp.TextResourceContents{
34+
URI: "test://resource1",
35+
MIMEType: "text/plain",
36+
Text: "test content 1",
37+
},
38+
}, nil
39+
},
40+
)
41+
42+
// Add a second resource
43+
server.AddResource(
44+
mcp.NewResource(
45+
"test://resource2",
46+
"Resource 2",
47+
mcp.WithResourceDescription("Test resource 2"),
48+
mcp.WithMIMEType("text/plain"),
49+
),
50+
func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
51+
return []mcp.ResourceContents{
52+
mcp.TextResourceContents{
53+
URI: "test://resource2",
54+
MIMEType: "text/plain",
55+
Text: "test content 2",
56+
},
57+
}, nil
58+
},
59+
)
60+
61+
// First, verify we have two resources
62+
response := server.HandleMessage(context.Background(), []byte(`{
63+
"jsonrpc": "2.0",
64+
"id": 1,
65+
"method": "resources/list"
66+
}`))
67+
resp, ok := response.(mcp.JSONRPCResponse)
68+
assert.True(t, ok)
69+
result, ok := resp.Result.(mcp.ListResourcesResult)
70+
assert.True(t, ok)
71+
assert.Len(t, result.Resources, 2)
72+
73+
// Now register session to receive notifications
74+
err := server.RegisterSession(context.TODO(), &fakeSession{
75+
sessionID: "test",
76+
notificationChannel: notificationChannel,
77+
initialized: true,
78+
})
79+
require.NoError(t, err)
80+
81+
// Now remove one resource
82+
server.RemoveResource("test://resource1")
83+
},
84+
expectedNotifications: 1,
85+
validate: func(t *testing.T, notifications []mcp.JSONRPCNotification, resourcesList mcp.JSONRPCMessage) {
86+
// Check that we received a list_changed notification
87+
assert.Equal(t, "resources/list_changed", notifications[0].Method)
88+
89+
// Verify we now have only one resource
90+
resp, ok := resourcesList.(mcp.JSONRPCResponse)
91+
assert.True(t, ok, "Expected JSONRPCResponse, got %T", resourcesList)
92+
93+
result, ok := resp.Result.(mcp.ListResourcesResult)
94+
assert.True(t, ok, "Expected ListResourcesResult, got %T", resp.Result)
95+
96+
assert.Len(t, result.Resources, 1)
97+
assert.Equal(t, "Resource 2", result.Resources[0].Name)
98+
},
99+
},
100+
{
101+
name: "RemoveResource with non-existent resource does nothing",
102+
action: func(t *testing.T, server *MCPServer, notificationChannel chan mcp.JSONRPCNotification) {
103+
// Add a test resource
104+
server.AddResource(
105+
mcp.NewResource(
106+
"test://resource1",
107+
"Resource 1",
108+
mcp.WithResourceDescription("Test resource 1"),
109+
mcp.WithMIMEType("text/plain"),
110+
),
111+
func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
112+
return []mcp.ResourceContents{
113+
mcp.TextResourceContents{
114+
URI: "test://resource1",
115+
MIMEType: "text/plain",
116+
Text: "test content 1",
117+
},
118+
}, nil
119+
},
120+
)
121+
122+
// Register session to receive notifications
123+
err := server.RegisterSession(context.TODO(), &fakeSession{
124+
sessionID: "test",
125+
notificationChannel: notificationChannel,
126+
initialized: true,
127+
})
128+
require.NoError(t, err)
129+
130+
// Remove a non-existent resource
131+
server.RemoveResource("test://nonexistent")
132+
},
133+
expectedNotifications: 1, // Still sends a notification
134+
validate: func(t *testing.T, notifications []mcp.JSONRPCNotification, resourcesList mcp.JSONRPCMessage) {
135+
// Check that we received a list_changed notification
136+
assert.Equal(t, "resources/list_changed", notifications[0].Method)
137+
138+
// The original resource should still be there
139+
resp, ok := resourcesList.(mcp.JSONRPCResponse)
140+
assert.True(t, ok)
141+
142+
result, ok := resp.Result.(mcp.ListResourcesResult)
143+
assert.True(t, ok)
144+
145+
assert.Len(t, result.Resources, 1)
146+
assert.Equal(t, "Resource 1", result.Resources[0].Name)
147+
},
148+
},
149+
{
150+
name: "RemoveResource with no listChanged capability doesn't send notification",
151+
action: func(t *testing.T, server *MCPServer, notificationChannel chan mcp.JSONRPCNotification) {
152+
// Create a new server without listChanged capability
153+
noListChangedServer := NewMCPServer(
154+
"test-server",
155+
"1.0.0",
156+
WithResourceCapabilities(true, false), // Subscribe but not listChanged
157+
)
158+
159+
// Add a resource
160+
noListChangedServer.AddResource(
161+
mcp.NewResource(
162+
"test://resource1",
163+
"Resource 1",
164+
mcp.WithResourceDescription("Test resource 1"),
165+
mcp.WithMIMEType("text/plain"),
166+
),
167+
func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
168+
return []mcp.ResourceContents{
169+
mcp.TextResourceContents{
170+
URI: "test://resource1",
171+
MIMEType: "text/plain",
172+
Text: "test content 1",
173+
},
174+
}, nil
175+
},
176+
)
177+
178+
// Register session to receive notifications
179+
err := noListChangedServer.RegisterSession(context.TODO(), &fakeSession{
180+
sessionID: "test",
181+
notificationChannel: notificationChannel,
182+
initialized: true,
183+
})
184+
require.NoError(t, err)
185+
186+
// Remove the resource
187+
noListChangedServer.RemoveResource("test://resource1")
188+
189+
// The test can now proceed without waiting for notifications
190+
// since we don't expect any
191+
},
192+
expectedNotifications: 0, // No notifications expected
193+
validate: func(t *testing.T, notifications []mcp.JSONRPCNotification, resourcesList mcp.JSONRPCMessage) {
194+
// Nothing to do here, we're just verifying that no notifications were sent
195+
assert.Empty(t, notifications)
196+
},
197+
},
198+
}
199+
200+
for _, tt := range tests {
201+
t.Run(tt.name, func(t *testing.T) {
202+
ctx := context.Background()
203+
server := NewMCPServer(
204+
"test-server",
205+
"1.0.0",
206+
WithResourceCapabilities(true, true),
207+
)
208+
209+
// Initialize the server
210+
_ = server.HandleMessage(ctx, []byte(`{
211+
"jsonrpc": "2.0",
212+
"id": 1,
213+
"method": "initialize"
214+
}`))
215+
216+
notificationChannel := make(chan mcp.JSONRPCNotification, 100)
217+
notifications := make([]mcp.JSONRPCNotification, 0)
218+
219+
tt.action(t, server, notificationChannel)
220+
221+
// Collect notifications with a timeout
222+
if tt.expectedNotifications > 0 {
223+
for i := 0; i < tt.expectedNotifications; i++ {
224+
select {
225+
case notification := <-notificationChannel:
226+
notifications = append(notifications, notification)
227+
case <-time.After(1 * time.Second):
228+
t.Fatalf("Expected %d notifications but only received %d", tt.expectedNotifications, len(notifications))
229+
}
230+
}
231+
} else {
232+
// If no notifications expected, wait a brief period to ensure none are sent
233+
select {
234+
case notification := <-notificationChannel:
235+
notifications = append(notifications, notification)
236+
case <-time.After(100 * time.Millisecond):
237+
// This is the expected path - no notifications
238+
}
239+
}
240+
241+
// Get final resources list
242+
listMessage := `{
243+
"jsonrpc": "2.0",
244+
"id": 1,
245+
"method": "resources/list"
246+
}`
247+
resourcesList := server.HandleMessage(ctx, []byte(listMessage))
248+
249+
// Validate the results
250+
tt.validate(t, notifications, resourcesList)
251+
})
252+
}
253+
}

server/server.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,18 @@ func (s *MCPServer) AddResource(
419419
}
420420
}
421421

422+
// RemoveResource removes a resource from the server
423+
func (s *MCPServer) RemoveResource(uri string) {
424+
s.mu.Lock()
425+
delete(s.resources, uri)
426+
s.mu.Unlock()
427+
428+
// Send notification to all initialized sessions if listChanged capability is enabled
429+
if s.capabilities.resources != nil && s.capabilities.resources.listChanged {
430+
s.sendNotificationToAllClients("resources/list_changed", nil)
431+
}
432+
}
433+
422434
// AddResourceTemplate registers a new resource template and its handler
423435
func (s *MCPServer) AddResourceTemplate(
424436
template mcp.ResourceTemplate,

0 commit comments

Comments
 (0)