From 5e2f36946e11232dec49671c53371ef9569c5bae Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 17 Oct 2025 15:13:10 +0300 Subject: [PATCH 1/8] feat: add session resource helper functions - Add ErrSessionDoesNotSupportResources error constant - Implement AddSessionResource() helper for adding single resource - Implement AddSessionResources() helper for batch resource operations - Implement DeleteSessionResources() helper for removing resources - Auto-register resource capabilities with listChanged=true for session resources - Send notifications/resources/list_changed when resources are modified - Match session tools helper pattern for consistency --- server/errors.go | 11 ++-- server/session.go | 132 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/server/errors.go b/server/errors.go index 3864f36f7..4668e4591 100644 --- a/server/errors.go +++ b/server/errors.go @@ -13,11 +13,12 @@ var ( ErrToolNotFound = errors.New("tool not found") // Session-related errors - ErrSessionNotFound = errors.New("session not found") - ErrSessionExists = errors.New("session already exists") - ErrSessionNotInitialized = errors.New("session not properly initialized") - ErrSessionDoesNotSupportTools = errors.New("session does not support per-session tools") - ErrSessionDoesNotSupportLogging = errors.New("session does not support setting logging level") + ErrSessionNotFound = errors.New("session not found") + ErrSessionExists = errors.New("session already exists") + ErrSessionNotInitialized = errors.New("session not properly initialized") + ErrSessionDoesNotSupportTools = errors.New("session does not support per-session tools") + ErrSessionDoesNotSupportResources = errors.New("session does not support per-session resources") + ErrSessionDoesNotSupportLogging = errors.New("session does not support setting logging level") // Notification-related errors ErrNotificationNotInitialized = errors.New("notification channel not initialized") diff --git a/server/session.go b/server/session.go index 33a21136d..5463549ad 100644 --- a/server/session.go +++ b/server/session.go @@ -460,3 +460,135 @@ func (s *MCPServer) DeleteSessionTools(sessionID string, names ...string) error return nil } + +// AddSessionResource adds a resource for a specific session +func (s *MCPServer) AddSessionResource(sessionID string, resource mcp.Resource, handler ResourceHandlerFunc) error { + return s.AddSessionResources(sessionID, ServerResource{Resource: resource, Handler: handler}) +} + +// AddSessionResources adds resources for a specific session +func (s *MCPServer) AddSessionResources(sessionID string, resources ...ServerResource) error { + sessionValue, ok := s.sessions.Load(sessionID) + if !ok { + return ErrSessionNotFound + } + + session, ok := sessionValue.(SessionWithResources) + if !ok { + return ErrSessionDoesNotSupportResources + } + + // For session resources, we want listChanged enabled by default + s.implicitlyRegisterCapabilities( + func() bool { return s.capabilities.resources != nil }, + func() { s.capabilities.resources = &resourceCapabilities{listChanged: true} }, + ) + + // Get existing resources (this should return a thread-safe copy) + sessionResources := session.GetSessionResources() + + // Create a new map to avoid concurrent modification issues + newSessionResources := make(map[string]ServerResource, len(sessionResources)+len(resources)) + + // Copy existing resources + for k, v := range sessionResources { + newSessionResources[k] = v + } + + // Add new resources + for _, resource := range resources { + newSessionResources[resource.Resource.URI] = resource + } + + // Set the resources (this should be thread-safe) + session.SetSessionResources(newSessionResources) + + // It only makes sense to send resource notifications to initialized sessions -- + // if we're not initialized yet the client can't possibly have sent their + // initial resources/list message. + // + // For initialized sessions, honor resources.listChanged, which is specifically + // about whether notifications will be sent or not. + // see + if session.Initialized() && s.capabilities.resources != nil && s.capabilities.resources.listChanged { + // Send notification only to this session + if err := s.SendNotificationToSpecificClient(sessionID, "notifications/resources/list_changed", nil); err != nil { + // Log the error but don't fail the operation + // The resources were successfully added, but notification failed + if s.hooks != nil && len(s.hooks.OnError) > 0 { + hooks := s.hooks + go func(sID string, hooks *Hooks) { + ctx := context.Background() + hooks.onError(ctx, nil, "notification", map[string]any{ + "method": "notifications/resources/list_changed", + "sessionID": sID, + }, fmt.Errorf("failed to send notification after adding resources: %w", err)) + }(sessionID, hooks) + } + } + } + + return nil +} + +// DeleteSessionResources removes resources from a specific session +func (s *MCPServer) DeleteSessionResources(sessionID string, uris ...string) error { + sessionValue, ok := s.sessions.Load(sessionID) + if !ok { + return ErrSessionNotFound + } + + session, ok := sessionValue.(SessionWithResources) + if !ok { + return ErrSessionDoesNotSupportResources + } + + // Get existing resources (this should return a thread-safe copy) + sessionResources := session.GetSessionResources() + if sessionResources == nil { + return nil + } + + // Create a new map to avoid concurrent modification issues + newSessionResources := make(map[string]ServerResource, len(sessionResources)) + + // Copy existing resources except those being deleted + for k, v := range sessionResources { + newSessionResources[k] = v + } + + // Remove specified resources + for _, uri := range uris { + delete(newSessionResources, uri) + } + + // Set the resources (this should be thread-safe) + session.SetSessionResources(newSessionResources) + + // It only makes sense to send resource notifications to initialized sessions -- + // if we're not initialized yet the client can't possibly have sent their + // initial resources/list message. + // + // For initialized sessions, honor resources.listChanged, which is specifically + // about whether notifications will be sent or not. + // see + if session.Initialized() && s.capabilities.resources != nil && s.capabilities.resources.listChanged { + // Send notification only to this session + if err := s.SendNotificationToSpecificClient(sessionID, "notifications/resources/list_changed", nil); err != nil { + // Log the error but don't fail the operation + // The resources were successfully deleted, but notification failed + if s.hooks != nil && len(s.hooks.OnError) > 0 { + hooks := s.hooks + go func(sID string, hooks *Hooks) { + ctx := context.Background() + hooks.onError(ctx, nil, "notification", map[string]any{ + "method": "notifications/resources/list_changed", + "sessionID": sID, + }, fmt.Errorf("failed to send notification after deleting resources: %w", err)) + }(sessionID, hooks) + } + } + } + + return nil +} From 0f35f2ed1d5076b5cc9908743bbe4555dff99401 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 17 Oct 2025 15:30:26 +0300 Subject: [PATCH 2/8] update --- server/session_resource_helpers_test.go | 701 ++++++++++++++++++++++++ www/docs/pages/servers/resources.mdx | 71 ++- www/docs/pages/servers/tools.mdx | 64 ++- 3 files changed, 834 insertions(+), 2 deletions(-) create mode 100644 server/session_resource_helpers_test.go diff --git a/server/session_resource_helpers_test.go b/server/session_resource_helpers_test.go new file mode 100644 index 000000000..235117620 --- /dev/null +++ b/server/session_resource_helpers_test.go @@ -0,0 +1,701 @@ +package server + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAddSessionResource tests adding a single resource to a session +func TestAddSessionResource(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + ctx := context.Background() + + // Create a session with resources support + sessionChan := make(chan mcp.JSONRPCNotification, 10) + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: sessionChan, + initialized: true, + sessionResources: make(map[string]ServerResource), + } + + // Register the session + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Add a single session resource with handler + resource := mcp.NewResource("test://session-resource", "Session Resource") + handler := func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: request.Params.URI, + MIMEType: "text/plain", + Text: "session resource content", + }, + }, nil + } + + err = server.AddSessionResource(session.SessionID(), resource, handler) + require.NoError(t, err) + + // Check that notification was sent + select { + case notification := <-sessionChan: + assert.Equal(t, "notifications/resources/list_changed", notification.Method) + case <-time.After(100 * time.Millisecond): + t.Error("Expected notification not received") + } + + // Verify resource was added to session + sessionResources := session.GetSessionResources() + assert.Len(t, sessionResources, 1) + assert.Contains(t, sessionResources, "test://session-resource") + + // Verify the handler works + serverResource := sessionResources["test://session-resource"] + contents, err := serverResource.Handler(ctx, mcp.ReadResourceRequest{Params: mcp.ReadResourceParams{URI: "test://session-resource"}}) + require.NoError(t, err) + assert.Len(t, contents, 1) + assert.Equal(t, "session resource content", contents[0].(mcp.TextResourceContents).Text) +} + +// TestAddSessionResources tests adding multiple resources to a session +func TestAddSessionResources(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + ctx := context.Background() + + // Create a session with resources support + sessionChan := make(chan mcp.JSONRPCNotification, 10) + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: sessionChan, + initialized: true, + sessionResources: make(map[string]ServerResource), + } + + // Register the session + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Add multiple session resources + resources := []ServerResource{ + { + Resource: mcp.NewResource("test://resource1", "Resource 1"), + Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "content 1"}, + }, nil + }, + }, + { + Resource: mcp.NewResource("test://resource2", "Resource 2"), + Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "content 2"}, + }, nil + }, + }, + } + + err = server.AddSessionResources(session.SessionID(), resources...) + require.NoError(t, err) + + // Check that only ONE notification was sent for batch addition + select { + case notification := <-sessionChan: + assert.Equal(t, "notifications/resources/list_changed", notification.Method) + case <-time.After(100 * time.Millisecond): + t.Error("Expected notification not received") + } + + // Ensure no additional notifications + select { + case <-sessionChan: + t.Error("Unexpected additional notification received") + case <-time.After(50 * time.Millisecond): + // Expected: no more notifications + } + + // Verify all resources were added + sessionResources := session.GetSessionResources() + assert.Len(t, sessionResources, 2) + assert.Contains(t, sessionResources, "test://resource1") + assert.Contains(t, sessionResources, "test://resource2") + + // Test overwriting existing resources + updatedResource := ServerResource{ + Resource: mcp.NewResource("test://resource1", "Updated Resource 1"), + Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "updated content 1"}, + }, nil + }, + } + + err = server.AddSessionResources(session.SessionID(), updatedResource) + require.NoError(t, err) + + // Verify resource was updated + sessionResources = session.GetSessionResources() // Refresh the map + contents, err := sessionResources["test://resource1"].Handler(ctx, mcp.ReadResourceRequest{Params: mcp.ReadResourceParams{URI: "test://resource1"}}) + require.NoError(t, err) + assert.Equal(t, "updated content 1", contents[0].(mcp.TextResourceContents).Text) +} + +// TestDeleteSessionResources tests removing resources from a session +func TestDeleteSessionResources(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + ctx := context.Background() + + // Create a session with pre-existing resources + sessionChan := make(chan mcp.JSONRPCNotification, 10) + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: sessionChan, + initialized: true, + sessionResources: map[string]ServerResource{ + "test://resource1": {Resource: mcp.NewResource("test://resource1", "Resource 1")}, + "test://resource2": {Resource: mcp.NewResource("test://resource2", "Resource 2")}, + "test://resource3": {Resource: mcp.NewResource("test://resource3", "Resource 3")}, + }, + } + + // Register the session + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Delete subset of resources + err = server.DeleteSessionResources(session.SessionID(), "test://resource1", "test://resource3") + require.NoError(t, err) + + // Check that notification was sent + select { + case notification := <-sessionChan: + assert.Equal(t, "notifications/resources/list_changed", notification.Method) + case <-time.After(100 * time.Millisecond): + t.Error("Expected notification not received") + } + + // Verify correct resources were removed + sessionResources := session.GetSessionResources() + assert.Len(t, sessionResources, 1) + assert.NotContains(t, sessionResources, "test://resource1") + assert.Contains(t, sessionResources, "test://resource2") + assert.NotContains(t, sessionResources, "test://resource3") + + // Delete non-existent resource (should not error) + err = server.DeleteSessionResources(session.SessionID(), "test://nonexistent") + require.NoError(t, err) + + // Verify another notification was sent + select { + case notification := <-sessionChan: + assert.Equal(t, "notifications/resources/list_changed", notification.Method) + case <-time.After(100 * time.Millisecond): + t.Error("Expected notification not received") + } +} + +// TestSessionResourcesWithGlobalResources tests merging of global and session resources +func TestSessionResourcesWithGlobalResources(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + ctx := context.Background() + + // Add global resources + server.AddResource( + mcp.NewResource("test://global1", "Global Resource 1"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "global content 1"}, + }, nil + }, + ) + server.AddResource( + mcp.NewResource("test://global2", "Global Resource 2"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "global content 2"}, + }, nil + }, + ) + + // Create a session + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: make(chan mcp.JSONRPCNotification, 10), + initialized: true, + sessionResources: make(map[string]ServerResource), + } + + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Add session resource that overrides a global resource + err = server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://global1", "Session Override Resource"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "session override content"}, + }, nil + }, + ) + require.NoError(t, err) + + // Add a session-only resource + err = server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://session1", "Session Resource 1"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "session content 1"}, + }, nil + }, + ) + require.NoError(t, err) + + // Get effective resources (global + session) + sessionResources := session.GetSessionResources() + + // Session should have 2 session-specific resources + assert.Len(t, sessionResources, 2) + + // Verify that session resource overrides work correctly + overriddenResource := sessionResources["test://global1"] + assert.Equal(t, "Session Override Resource", overriddenResource.Resource.Name) + + // Test read operations use correct handlers + contents, err := sessionResources["test://global1"].Handler(ctx, mcp.ReadResourceRequest{Params: mcp.ReadResourceParams{URI: "test://global1"}}) + require.NoError(t, err) + assert.Equal(t, "session override content", contents[0].(mcp.TextResourceContents).Text) +} + +// TestAddSessionResourcesUninitialized tests adding resources to uninitialized session +func TestAddSessionResourcesUninitialized(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + ctx := context.Background() + + // Create an uninitialized session + sessionChan := make(chan mcp.JSONRPCNotification, 10) + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: sessionChan, + initialized: false, // Not initialized + sessionResources: make(map[string]ServerResource), + } + + // Register the session + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Add resources to uninitialized session + err = server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://resource1", "Resource 1"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "content 1"}, + }, nil + }, + ) + require.NoError(t, err) + + // Verify NO notification was sent (session not initialized) + select { + case <-sessionChan: + t.Error("Unexpected notification received for uninitialized session") + case <-time.After(100 * time.Millisecond): + // Expected: no notification + } + + // Verify resource was still added + sessionResources := session.GetSessionResources() + assert.Len(t, sessionResources, 1) + assert.Contains(t, sessionResources, "test://resource1") + + // Initialize session + session.Initialize() + + // Add another resource after initialization + err = server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://resource2", "Resource 2"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "content 2"}, + }, nil + }, + ) + require.NoError(t, err) + + // Now notification should be sent + select { + case notification := <-sessionChan: + assert.Equal(t, "notifications/resources/list_changed", notification.Method) + case <-time.After(100 * time.Millisecond): + t.Error("Expected notification not received after initialization") + } + + // Verify both resources are accessible + assert.Len(t, session.GetSessionResources(), 2) +} + +// TestDeleteSessionResourcesUninitialized tests deleting resources from uninitialized session +func TestDeleteSessionResourcesUninitialized(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + ctx := context.Background() + + // Create an uninitialized session with resources + sessionChan := make(chan mcp.JSONRPCNotification, 10) + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: sessionChan, + initialized: false, // Not initialized + sessionResources: map[string]ServerResource{ + "test://resource1": {Resource: mcp.NewResource("test://resource1", "Resource 1")}, + "test://resource2": {Resource: mcp.NewResource("test://resource2", "Resource 2")}, + }, + } + + // Register the session + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Delete resources from uninitialized session + err = server.DeleteSessionResources(session.SessionID(), "test://resource1") + require.NoError(t, err) + + // Verify NO notification was sent (session not initialized) + select { + case <-sessionChan: + t.Error("Unexpected notification received for uninitialized session") + case <-time.After(100 * time.Millisecond): + // Expected: no notification + } + + // Verify resource was still deleted + sessionResources := session.GetSessionResources() + assert.Len(t, sessionResources, 1) + assert.NotContains(t, sessionResources, "test://resource1") + assert.Contains(t, sessionResources, "test://resource2") +} + +// TestSessionResourceCapabilitiesBehavior tests capability-dependent notification behavior +func TestSessionResourceCapabilitiesBehavior(t *testing.T) { + tests := []struct { + name string + setupServer func() *MCPServer + expectNotification bool + }{ + { + name: "listChanged=true sends notifications", + setupServer: func() *MCPServer { + return NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + }, + expectNotification: true, + }, + { + name: "listChanged=false sends no notifications", + setupServer: func() *MCPServer { + return NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, false)) + }, + expectNotification: false, + }, + { + name: "no resource capability auto-registers and sends notifications", + setupServer: func() *MCPServer { + return NewMCPServer("test-server", "1.0.0") + }, + expectNotification: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := tt.setupServer() + ctx := context.Background() + + sessionChan := make(chan mcp.JSONRPCNotification, 10) + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: sessionChan, + initialized: true, + sessionResources: make(map[string]ServerResource), + } + + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Add a resource + err = server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://resource", "Test Resource"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "content"}, + }, nil + }, + ) + require.NoError(t, err) + + // Check notification based on expectation + if tt.expectNotification { + select { + case notification := <-sessionChan: + assert.Equal(t, "notifications/resources/list_changed", notification.Method) + case <-time.After(100 * time.Millisecond): + t.Error("Expected notification not received") + } + } else { + select { + case <-sessionChan: + t.Error("Unexpected notification received") + case <-time.After(100 * time.Millisecond): + // Expected: no notification + } + } + + // If no capability was set, verify it was auto-registered + if server.capabilities.resources == nil { + // After first Add, capability should be auto-registered + assert.NotNil(t, server.capabilities.resources) + } + }) + } +} + +// TestSessionResourceOperationsAfterUnregister tests operations on unregistered sessions +func TestSessionResourceOperationsAfterUnregister(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + ctx := context.Background() + + // Create and register a session + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: make(chan mcp.JSONRPCNotification, 10), + initialized: true, + sessionResources: make(map[string]ServerResource), + } + + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Unregister the session + server.UnregisterSession(ctx, session.SessionID()) + + // Attempt to add a resource (should fail) + err = server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://resource", "Test Resource"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return nil, nil + }, + ) + assert.ErrorIs(t, err, ErrSessionNotFound) + + // Attempt to delete resources (should fail) + err = server.DeleteSessionResources(session.SessionID(), "test://resource") + assert.ErrorIs(t, err, ErrSessionNotFound) +} + +// TestSessionResourcesConcurrency tests thread-safe resource operations +func TestSessionResourcesConcurrency(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + ctx := context.Background() + + // Create a session + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: make(chan mcp.JSONRPCNotification, 100), + initialized: true, + sessionResources: make(map[string]ServerResource), + } + + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Run concurrent operations + done := make(chan bool) + errors := make(chan error, 100) + + // Goroutine 1: Add resources + go func() { + for i := 0; i < 10; i++ { + uri := fmt.Sprintf("test://resource%d", i) + err := server.AddSessionResource( + session.SessionID(), + mcp.NewResource(uri, fmt.Sprintf("Resource %d", i)), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "content"}, + }, nil + }, + ) + if err != nil { + errors <- err + } + } + done <- true + }() + + // Goroutine 2: Delete resources + go func() { + time.Sleep(10 * time.Millisecond) // Let some adds happen first + for i := 0; i < 5; i++ { + uri := fmt.Sprintf("test://resource%d", i*2) + err := server.DeleteSessionResources(session.SessionID(), uri) + if err != nil { + errors <- err + } + } + done <- true + }() + + // Goroutine 3: Add and delete same resource repeatedly + go func() { + for i := 0; i < 10; i++ { + // Add + err := server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://concurrent", "Concurrent Resource"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "concurrent"}, + }, nil + }, + ) + if err != nil { + errors <- err + } + // Delete + err = server.DeleteSessionResources(session.SessionID(), "test://concurrent") + if err != nil { + errors <- err + } + } + done <- true + }() + + // Wait for all goroutines to complete + for i := 0; i < 3; i++ { + <-done + } + + // Check for errors + close(errors) + for err := range errors { + t.Errorf("Concurrent operation error: %v", err) + } + + // Verify final state is consistent + sessionResources := session.GetSessionResources() + assert.NotNil(t, sessionResources) + // The exact count depends on timing, but it should be between 0 and 10 + assert.GreaterOrEqual(t, len(sessionResources), 0) + assert.LessOrEqual(t, len(sessionResources), 11) // 10 regular + 1 concurrent +} + +// TestSessionDoesNotSupportResources tests error handling for incompatible sessions +func TestSessionDoesNotSupportResources(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0") + ctx := context.Background() + + // Create a session that doesn't implement SessionWithResources + session := &sessionTestClientWithTools{ + sessionID: "session-1", + notificationChannel: make(chan mcp.JSONRPCNotification, 10), + initialized: true, + sessionTools: make(map[string]ServerTool), + } + + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Attempt to add a resource (should fail) + err = server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://resource", "Test Resource"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return nil, nil + }, + ) + assert.ErrorIs(t, err, ErrSessionDoesNotSupportResources) + + // Attempt to add multiple resources (should fail) + err = server.AddSessionResources( + session.SessionID(), + ServerResource{ + Resource: mcp.NewResource("test://resource", "Test Resource"), + Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return nil, nil + }, + }, + ) + assert.ErrorIs(t, err, ErrSessionDoesNotSupportResources) + + // Attempt to delete resources (should fail) + err = server.DeleteSessionResources(session.SessionID(), "test://resource") + assert.ErrorIs(t, err, ErrSessionDoesNotSupportResources) +} + +// TestNotificationErrorHandling tests graceful handling of notification failures +func TestNotificationErrorHandling(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) + + // Set up error tracking + var capturedError error + if server.hooks == nil { + server.hooks = &Hooks{} + } + server.hooks.OnError = []OnErrorHookFunc{ + func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) { + capturedError = err + }, + } + + ctx := context.Background() + + // Create a session with a blocking notification channel + blockingChan := make(chan mcp.JSONRPCNotification) // No buffer, will block + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: blockingChan, + initialized: true, + sessionResources: make(map[string]ServerResource), + } + + err := server.RegisterSession(ctx, session) + require.NoError(t, err) + + // Add a resource (notification will fail due to blocking channel) + err = server.AddSessionResource( + session.SessionID(), + mcp.NewResource("test://resource", "Test Resource"), + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{URI: request.Params.URI, MIMEType: "text/plain", Text: "content"}, + }, nil + }, + ) + + // Operation should succeed despite notification failure + require.NoError(t, err) + + // Give some time for the notification timeout + time.Sleep(150 * time.Millisecond) + + // Verify error was logged + assert.NotNil(t, capturedError) + assert.Contains(t, capturedError.Error(), "blocked") + + // Verify resource was actually added despite notification failure + sessionResources := session.GetSessionResources() + assert.Len(t, sessionResources, 1) + assert.Contains(t, sessionResources, "test://resource") +} diff --git a/www/docs/pages/servers/resources.mdx b/www/docs/pages/servers/resources.mdx index 480cca01a..140e2666f 100644 --- a/www/docs/pages/servers/resources.mdx +++ b/www/docs/pages/servers/resources.mdx @@ -547,7 +547,68 @@ func (h *CachedResourceHandler) HandleResource(ctx context.Context, req mcp.Read ### Session-specific Resources -You can add resources to a specific client session using the `SessionWithResources` interface. +You can add resources to a specific client session, allowing different clients to see different resources or override global resources with session-specific implementations. + +#### Using Helper Functions (Recommended) + +The server provides convenient helper functions that mirror the session tool helpers: + +```go +// Add a single session resource +userResource := mcp.NewResource( + "user://profile", + "User Profile", + mcp.WithResourceDescription("Current user's profile data"), +) + +err := s.AddSessionResource( + sessionID, + userResource, + func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + // This handler is only available to this specific session + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: request.Params.URI, + MIMEType: "application/json", + Text: getUserProfile(sessionID), + }, + }, nil + }, +) +if err != nil { + log.Printf("Failed to add session resource: %v", err) +} + +// Add multiple session resources at once +err = s.AddSessionResources( + sessionID, + server.ServerResource{ + Resource: mcp.NewResource("user://settings", "User Settings"), + Handler: func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return getUserSettings(sessionID) + }, + }, + server.ServerResource{ + Resource: mcp.NewResource("user://history", "User History"), + Handler: func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return getUserHistory(sessionID) + }, + }, +) +if err != nil { + log.Printf("Failed to add session resources: %v", err) +} + +// Delete session resources when no longer needed +err = s.DeleteSessionResources(sessionID, "user://profile", "user://settings") +if err != nil { + log.Printf("Failed to delete session resources: %v", err) +} +``` + +#### Direct Interface Usage + +You can also work directly with the `SessionWithResources` interface: ```go sseServer := server.NewSSEServer( @@ -575,6 +636,14 @@ sseServer := server.NewSSEServer( ) ``` +#### Important Notes + +- Session resources override global resources with the same URI +- Notifications (`resources/list_changed`) are automatically sent when resources are added/removed +- The server automatically registers resource capabilities when session resources are first added +- Operations are thread-safe and can be called concurrently +- Resources are only available to initialized sessions unless explicitly added before initialization + ## Next Steps - **[Tools](/servers/tools)** - Learn to implement interactive functionality diff --git a/www/docs/pages/servers/tools.mdx b/www/docs/pages/servers/tools.mdx index 21a2c5191..44c383e3c 100644 --- a/www/docs/pages/servers/tools.mdx +++ b/www/docs/pages/servers/tools.mdx @@ -1049,7 +1049,61 @@ func addConditionalTools(s *server.MCPServer, userRole string) { ### Session-specific Tools -You can add tools to a specific client session using the `SessionWithTools` interface. +You can add tools to a specific client session, allowing different clients to have access to different tools or different implementations of the same tool. + +#### Using Helper Functions (Recommended) + +The server provides convenient helper functions for managing session tools: + +```go +// Add a single session tool +userTool := mcp.NewTool( + "get_user_data", + mcp.WithDescription("Get current user's data"), +) + +err := s.AddSessionTool( + sessionID, + userTool, + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // This handler is only available to this specific session + return mcp.NewToolResultText("User data for " + sessionID), nil + }, +) +if err != nil { + log.Printf("Failed to add session tool: %v", err) +} + +// Add multiple session tools at once +err = s.AddSessionTools( + sessionID, + server.ServerTool{ + Tool: mcp.NewTool("user_settings", mcp.WithDescription("Manage user settings")), + Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleUserSettings(sessionID, req) + }, + }, + server.ServerTool{ + Tool: mcp.NewTool("user_history", mcp.WithDescription("Access user history")), + Handler: func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleUserHistory(sessionID, req) + }, + }, +) +if err != nil { + log.Printf("Failed to add session tools: %v", err) +} + +// Delete session tools when no longer needed +err = s.DeleteSessionTools(sessionID, "get_user_data", "user_settings") +if err != nil { + log.Printf("Failed to delete session tools: %v", err) +} +``` + +#### Direct Interface Usage + +You can also work directly with the `SessionWithTools` interface: ```go sseServer := server.NewSSEServer( @@ -1077,6 +1131,14 @@ sseServer := server.NewSSEServer( ) ``` +#### Important Notes + +- Session tools override global tools with the same name +- Notifications (`tools/list_changed`) are automatically sent when tools are added/removed +- The server automatically registers tool capabilities when session tools are first added +- Operations are thread-safe and can be called concurrently +- Tools are only available to initialized sessions unless explicitly added before initialization + ## Next Steps - **[Prompts](/servers/prompts)** - Learn to create reusable interaction templates From c9176b82e4735c38869f53c252385944c4878623 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 17 Oct 2025 15:41:23 +0300 Subject: [PATCH 3/8] test: fix race condition in TestNotificationErrorHandling Use channel instead of shared variable to avoid race condition when capturing errors from goroutines in the notification error handling test --- server/session_resource_helpers_test.go | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/server/session_resource_helpers_test.go b/server/session_resource_helpers_test.go index 235117620..a8472ad5b 100644 --- a/server/session_resource_helpers_test.go +++ b/server/session_resource_helpers_test.go @@ -648,14 +648,18 @@ func TestSessionDoesNotSupportResources(t *testing.T) { func TestNotificationErrorHandling(t *testing.T) { server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, true)) - // Set up error tracking - var capturedError error + // Set up error tracking with a channel to avoid race conditions + errorChan := make(chan error, 1) if server.hooks == nil { server.hooks = &Hooks{} } server.hooks.OnError = []OnErrorHookFunc{ func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) { - capturedError = err + select { + case errorChan <- err: + default: + // Channel already has an error, ignore subsequent ones + } }, } @@ -687,12 +691,15 @@ func TestNotificationErrorHandling(t *testing.T) { // Operation should succeed despite notification failure require.NoError(t, err) - // Give some time for the notification timeout - time.Sleep(150 * time.Millisecond) - - // Verify error was logged - assert.NotNil(t, capturedError) - assert.Contains(t, capturedError.Error(), "blocked") + // Wait for the error to be logged + select { + case capturedError := <-errorChan: + // Verify error was logged + assert.NotNil(t, capturedError) + assert.Contains(t, capturedError.Error(), "channel") + case <-time.After(200 * time.Millisecond): + t.Error("Expected error was not logged") + } // Verify resource was actually added despite notification failure sessionResources := session.GetSessionResources() From 988fc942ce82ad3308665f4f5979b5dcd3e7a479 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 17 Oct 2025 15:58:40 +0300 Subject: [PATCH 4/8] test: fix AddSessionResource assertions to validate state changes - Capture pre-call resource count before AddSessionResource - Assert resource is present and count increased after call - Validate listChanged behavior matches expectations - Fix auto-registration check to properly verify capabilities --- server/session_resource_helpers_test.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/server/session_resource_helpers_test.go b/server/session_resource_helpers_test.go index a8472ad5b..34fe93096 100644 --- a/server/session_resource_helpers_test.go +++ b/server/session_resource_helpers_test.go @@ -432,6 +432,9 @@ func TestSessionResourceCapabilitiesBehavior(t *testing.T) { err := server.RegisterSession(ctx, session) require.NoError(t, err) + // Capture pre-call state + preAddResourceCount := len(session.sessionResources) + // Add a resource err = server.AddSessionResource( session.SessionID(), @@ -444,6 +447,15 @@ func TestSessionResourceCapabilitiesBehavior(t *testing.T) { ) require.NoError(t, err) + // Verify post-call state: resource was added + assert.Contains(t, session.sessionResources, "test://resource", "Resource should be present after AddSessionResource") + assert.Equal(t, preAddResourceCount+1, len(session.sessionResources), "Resource count should increase by 1") + + // Verify the listChanged default behavior + if server.capabilities.resources != nil { + assert.Equal(t, server.capabilities.resources.listChanged, tt.expectNotification, "listChanged value should match expectation") + } + // Check notification based on expectation if tt.expectNotification { select { @@ -461,10 +473,12 @@ func TestSessionResourceCapabilitiesBehavior(t *testing.T) { } } - // If no capability was set, verify it was auto-registered - if server.capabilities.resources == nil { + // Verify auto-registration behavior for servers without initial resource capabilities + if tt.name == "no resource capability auto-registers and sends notifications" { // After first Add, capability should be auto-registered - assert.NotNil(t, server.capabilities.resources) + assert.NotNil(t, server.capabilities.resources, "Resource capability should be auto-registered") + // When auto-registered, default listChanged should be true + assert.Equal(t, true, server.capabilities.resources.listChanged, "Auto-registered resources should have listChanged=true by default") } }) } From c2ece3c3724acc31fb284354939b60906dd424fb Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 17 Oct 2025 16:04:58 +0300 Subject: [PATCH 5/8] test: fix assertion for non-existent resource deletion - Update test to assert no notification is sent when deleting non-existent resource - Replace expectation of notification with assertion that no notification occurs - Fail test if unexpected notification is received --- server/session_resource_helpers_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/session_resource_helpers_test.go b/server/session_resource_helpers_test.go index 34fe93096..d01034aa1 100644 --- a/server/session_resource_helpers_test.go +++ b/server/session_resource_helpers_test.go @@ -193,12 +193,12 @@ func TestDeleteSessionResources(t *testing.T) { err = server.DeleteSessionResources(session.SessionID(), "test://nonexistent") require.NoError(t, err) - // Verify another notification was sent + // Verify no notification is sent for non-existent resource deletion select { - case notification := <-sessionChan: - assert.Equal(t, "notifications/resources/list_changed", notification.Method) + case <-sessionChan: + t.Error("Unexpected notification received when deleting non-existent resource") case <-time.After(100 * time.Millisecond): - t.Error("Expected notification not received") + // Expected: no notification for non-existent resource } } From ac53b887fd170e3b1653c1f502fd810038a5d038 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 17 Oct 2025 16:08:07 +0300 Subject: [PATCH 6/8] test: properly validate global+session resource merging - Exercise server's ListResources and ReadResource handlers with session context - Assert global resources appear in merged list - Verify session resource overrides global resource with same URI - Confirm ReadResource uses session handler for overridden resources - Test that non-overridden global resources still use global handlers --- server/session_resource_helpers_test.go | 64 ++++++++++++++++++++----- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/server/session_resource_helpers_test.go b/server/session_resource_helpers_test.go index d01034aa1..3202909e2 100644 --- a/server/session_resource_helpers_test.go +++ b/server/session_resource_helpers_test.go @@ -260,20 +260,62 @@ func TestSessionResourcesWithGlobalResources(t *testing.T) { ) require.NoError(t, err) - // Get effective resources (global + session) - sessionResources := session.GetSessionResources() + // Create a context with the session for server operations + sessionCtx := server.WithContext(ctx, session) - // Session should have 2 session-specific resources - assert.Len(t, sessionResources, 2) + // Test ListResources to verify merge behavior + listResult, rerr := server.handleListResources(sessionCtx, "test-id", mcp.ListResourcesRequest{}) + require.Nil(t, rerr) + require.NotNil(t, listResult) - // Verify that session resource overrides work correctly - overriddenResource := sessionResources["test://global1"] - assert.Equal(t, "Session Override Resource", overriddenResource.Resource.Name) + // Should have 3 resources: global2, session-overridden global1, and session1 + assert.Len(t, listResult.Resources, 3) - // Test read operations use correct handlers - contents, err := sessionResources["test://global1"].Handler(ctx, mcp.ReadResourceRequest{Params: mcp.ReadResourceParams{URI: "test://global1"}}) - require.NoError(t, err) - assert.Equal(t, "session override content", contents[0].(mcp.TextResourceContents).Text) + // Verify all expected resources are present + resourceMap := make(map[string]mcp.Resource) + for _, r := range listResult.Resources { + resourceMap[r.URI] = r + } + + // Global resource 2 should appear unchanged + assert.Contains(t, resourceMap, "test://global2") + assert.Equal(t, "Global Resource 2", resourceMap["test://global2"].Name) + + // Global resource 1 should be overridden by session resource + assert.Contains(t, resourceMap, "test://global1") + assert.Equal(t, "Session Override Resource", resourceMap["test://global1"].Name) + + // Session-only resource should appear + assert.Contains(t, resourceMap, "test://session1") + assert.Equal(t, "Session Resource 1", resourceMap["test://session1"].Name) + + // Test ReadResource to verify handlers are correctly resolved + // Test reading the overridden resource - should use session handler + readResult1, rerr := server.handleReadResource(sessionCtx, "test-id", mcp.ReadResourceRequest{ + Params: mcp.ReadResourceParams{URI: "test://global1"}, + }) + require.Nil(t, rerr) + require.NotNil(t, readResult1) + assert.Len(t, readResult1.Contents, 1) + assert.Equal(t, "session override content", readResult1.Contents[0].(mcp.TextResourceContents).Text) + + // Test reading global resource that wasn't overridden + readResult2, rerr := server.handleReadResource(sessionCtx, "test-id", mcp.ReadResourceRequest{ + Params: mcp.ReadResourceParams{URI: "test://global2"}, + }) + require.Nil(t, rerr) + require.NotNil(t, readResult2) + assert.Len(t, readResult2.Contents, 1) + assert.Equal(t, "global content 2", readResult2.Contents[0].(mcp.TextResourceContents).Text) + + // Test reading session-only resource + readResult3, rerr := server.handleReadResource(sessionCtx, "test-id", mcp.ReadResourceRequest{ + Params: mcp.ReadResourceParams{URI: "test://session1"}, + }) + require.Nil(t, rerr) + require.NotNil(t, readResult3) + assert.Len(t, readResult3.Contents, 1) + assert.Equal(t, "session content 1", readResult3.Contents[0].(mcp.TextResourceContents).Text) } // TestAddSessionResourcesUninitialized tests adding resources to uninitialized session From 788e3b4f61f5d04079611172214b515889188520 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 17 Oct 2025 16:20:44 +0300 Subject: [PATCH 7/8] fix: only send resource notification when actually deleting resources - Modified DeleteSessionResources to track if resources were actually deleted - Only send list_changed notification if something was removed - Fixed race condition in TestStreamableHTTP_SessionWithResources by protecting shared state - Added sync.Once to prevent multiple WaitGroup.Done() calls --- server/session.go | 11 ++++++++--- server/streamable_http_test.go | 22 +++++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/server/session.go b/server/session.go index 5463549ad..c1eab9d1f 100644 --- a/server/session.go +++ b/server/session.go @@ -557,9 +557,13 @@ func (s *MCPServer) DeleteSessionResources(sessionID string, uris ...string) err newSessionResources[k] = v } - // Remove specified resources + // Remove specified resources and track if anything was actually deleted + actuallyDeleted := false for _, uri := range uris { - delete(newSessionResources, uri) + if _, exists := newSessionResources[uri]; exists { + delete(newSessionResources, uri) + actuallyDeleted = true + } } // Set the resources (this should be thread-safe) @@ -572,7 +576,8 @@ func (s *MCPServer) DeleteSessionResources(sessionID string, uris ...string) err // For initialized sessions, honor resources.listChanged, which is specifically // about whether notifications will be sent or not. // see - if session.Initialized() && s.capabilities.resources != nil && s.capabilities.resources.listChanged { + // Only send notification if something was actually deleted + if actuallyDeleted && session.Initialized() && s.capabilities.resources != nil && s.capabilities.resources.listChanged { // Send notification only to this session if err := s.SendNotificationToSpecificClient(sessionID, "notifications/resources/list_changed", nil); err != nil { // Log the error but don't fail the operation diff --git a/server/streamable_http_test.go b/server/streamable_http_test.go index 8d34c4461..e8820fae3 100644 --- a/server/streamable_http_test.go +++ b/server/streamable_http_test.go @@ -855,11 +855,11 @@ func TestStreamableHTTP_SessionWithTools(t *testing.T) { func TestStreamableHTTP_SessionWithResources(t *testing.T) { t.Run("SessionWithResources implementation", func(t *testing.T) { - // Create hooks to track sessions + var registeredSession SessionWithResources hooks := &Hooks{} - var registeredSession *streamableHttpSession var mu sync.Mutex var sessionRegistered sync.WaitGroup + var sessionRegisteredOnce sync.Once sessionRegistered.Add(1) hooks.AddOnRegisterSession(func(ctx context.Context, session ClientSession) { @@ -867,7 +867,9 @@ func TestStreamableHTTP_SessionWithResources(t *testing.T) { mu.Lock() registeredSession = s mu.Unlock() - sessionRegistered.Done() + sessionRegisteredOnce.Do(func() { + sessionRegistered.Done() + }) } }) @@ -956,11 +958,21 @@ func TestStreamableHTTP_SessionWithResources(t *testing.T) { }, }, } - registeredSession.SetSessionResources(resources) + mu.Lock() + session := registeredSession + mu.Unlock() + if session != nil { + session.SetSessionResources(resources) + } }(i) go func() { defer wg.Done() - _ = registeredSession.GetSessionResources() + mu.Lock() + session := registeredSession + mu.Unlock() + if session != nil { + _ = session.GetSessionResources() + } }() } wg.Wait() From 209e3fc4e68c5203bf55470e98622779d7ae526a Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 17 Oct 2025 16:30:54 +0300 Subject: [PATCH 8/8] Add RFC 3986 URI validation to AddSessionResources and optimize DeleteSessionResources - Validate Resource.URI is non-empty and conforms to RFC 3986 using url.ParseRequestURI - Return formatted errors for invalid or empty URIs instead of silently inserting them - Skip SetSessionResources call in DeleteSessionResources when no resources were actually deleted - Add comprehensive test for session resource overriding global resources --- server/session.go | 18 +++++++- server/session_test.go | 102 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/server/session.go b/server/session.go index c1eab9d1f..99d6db8d4 100644 --- a/server/session.go +++ b/server/session.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "net/url" "github.com/mark3labs/mcp-go/mcp" ) @@ -495,8 +496,18 @@ func (s *MCPServer) AddSessionResources(sessionID string, resources ...ServerRes newSessionResources[k] = v } - // Add new resources + // Add new resources with validation for _, resource := range resources { + // Validate that URI is non-empty + if resource.Resource.URI == "" { + return fmt.Errorf("resource URI cannot be empty") + } + + // Validate that URI conforms to RFC 3986 + if _, err := url.ParseRequestURI(resource.Resource.URI); err != nil { + return fmt.Errorf("invalid resource URI: %w", err) + } + newSessionResources[resource.Resource.URI] = resource } @@ -566,6 +577,11 @@ func (s *MCPServer) DeleteSessionResources(sessionID string, uris ...string) err } } + // Skip no-op write if nothing was actually deleted + if !actuallyDeleted { + return nil + } + // Set the resources (this should be thread-safe) session.SetSessionResources(newSessionResources) diff --git a/server/session_test.go b/server/session_test.go index 2c9aa4bff..308499a9b 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -439,6 +439,108 @@ func TestMCPServer_ToolsWithSessionTools(t *testing.T) { assert.True(t, found, "Should find the overridden global tool") } +func TestMCPServer_ResourcesWithSessionResources(t *testing.T) { + server := NewMCPServer("test-server", "1.0.0", WithResourceCapabilities(false, false)) + + // Add global resources + server.AddResources( + ServerResource{ + Resource: mcp.NewResource("test://global1", "global-resource-1"), + Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{mcp.TextResourceContents{ + URI: "test://global1", + Text: "global-resource-1 result", + }}, nil + }, + }, + ServerResource{ + Resource: mcp.NewResource("test://global2", "global-resource-2"), + Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{mcp.TextResourceContents{ + URI: "test://global2", + Text: "global-resource-2 result", + }}, nil + }, + }, + ) + + // Create a session with resources that override global-resource-1 + session := &sessionTestClientWithResources{ + sessionID: "session-1", + notificationChannel: make(chan mcp.JSONRPCNotification, 10), + initialized: true, + sessionResources: map[string]ServerResource{ + "test://global1": { + Resource: mcp.NewResource("test://global1", "global-resource-1-overridden"), + Handler: func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{mcp.TextResourceContents{ + URI: "test://global1", + Text: "session-overridden result", + }}, nil + }, + }, + }, + } + + // Register the session + err := server.RegisterSession(context.Background(), session) + require.NoError(t, err) + + // List resources with session context via HandleMessage + sessionCtx := server.WithContext(context.Background(), session) + resp := server.HandleMessage(sessionCtx, []byte(`{ + "jsonrpc": "2.0", + "id": 1, + "method": "resources/list" + }`)) + + jsonResp, ok := resp.(mcp.JSONRPCResponse) + require.True(t, ok, "Response should be a JSONRPCResponse") + + result, ok := jsonResp.Result.(mcp.ListResourcesResult) + require.True(t, ok, "Result should be a ListResourcesResult") + + // Should have 2 resources - global-resource-2 not overridden and global-resource-1 overridden + assert.Len(t, result.Resources, 2, "Should have 2 resources") + + // Find the resources and verify + resourceMap := make(map[string]mcp.Resource) + for _, resource := range result.Resources { + resourceMap[resource.URI] = resource + } + + // Verify global resource not overridden appears + require.Contains(t, resourceMap, "test://global2", "Should have non-overridden global resource") + assert.Equal(t, "global-resource-2", resourceMap["test://global2"].Name, "Global resource name should match") + + // Verify overridden global resource appears with session override + require.Contains(t, resourceMap, "test://global1", "Should have overridden global resource") + assert.Equal(t, "global-resource-1-overridden", resourceMap["test://global1"].Name, "Overridden resource name should match session version") + + // Read the overridden resource and confirm it returns the session handler's content + t.Run("read overridden resource via HandleMessage", func(t *testing.T) { + readResp := server.HandleMessage(sessionCtx, []byte(`{ + "jsonrpc": "2.0", + "id": 2, + "method": "resources/read", + "params": { + "uri": "test://global1" + } + }`)) + + readJSONResp, ok := readResp.(mcp.JSONRPCResponse) + require.True(t, ok, "Read response should be a JSONRPCResponse") + + readResult, ok := readJSONResp.Result.(mcp.ReadResourceResult) + require.True(t, ok, "Result should be a ReadResourceResult") + + require.Len(t, readResult.Contents, 1, "Should have one content item") + textContent, ok := readResult.Contents[0].(mcp.TextResourceContents) + require.True(t, ok, "Content should be TextResourceContents") + assert.Equal(t, "session-overridden result", textContent.Text, "Should return session handler's content") + }) +} + func TestMCPServer_AddSessionTools(t *testing.T) { server := NewMCPServer("test-server", "1.0.0", WithToolCapabilities(true)) ctx := context.Background()