Skip to content

Commit 55b03fc

Browse files
Add notification support
Co-authored-by: Kevin Fischer <[email protected]>
1 parent 8fbb2e8 commit 55b03fc

File tree

7 files changed

+711
-6
lines changed

7 files changed

+711
-6
lines changed

README.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ It implements the Model Context Protocol specification, handling model context r
3434
- Supports prompt registration and execution
3535
- Supports resource registration and retrieval
3636
- Supports stdio & Streamable HTTP (including SSE) transports
37+
- Supports notifications for list changes (tools, prompts, resources)
3738

3839
### Supported Methods
3940
- `initialize` - Initializes the protocol and returns server capabilities
@@ -46,13 +47,46 @@ It implements the Model Context Protocol specification, handling model context r
4647
- `resources/read` - Retrieves a specific resource by name
4748
- `resources/templates/list` - Lists all registered resource templates and their schemas
4849

50+
### Notifications
51+
52+
The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling.
53+
54+
#### Notification Methods
55+
56+
The server provides three notification methods:
57+
- `notify_tools_list_changed()` - Send a notification when the tools list changes
58+
- `notify_prompts_list_changed()` - Send a notification when the prompts list changes
59+
- `notify_resources_list_changed()` - Send a notification when the resources list changes
60+
61+
#### Notification Format
62+
63+
Notifications follow the JSON-RPC 2.0 specification and use these method names:
64+
- `notifications/tools/list_changed`
65+
- `notifications/prompts/list_changed`
66+
- `notifications/resources/list_changed`
67+
68+
#### Transport Support
69+
70+
- **HTTP Transport**: Notifications are sent as Server-Sent Events (SSE) to all connected sessions
71+
- **Stdio Transport**: Notifications are sent as JSON-RPC 2.0 messages to stdout
72+
73+
#### Usage Example
74+
75+
```ruby
76+
server = MCP::Server.new(name: "my_server")
77+
transport = MCP::Transports::HTTP.new(server)
78+
server.transport = transport
79+
80+
# When tools change, notify clients
81+
server.define_tool(name: "new_tool") { |**args| { result: "ok" } }
82+
server.notify_tools_list_changed()
83+
```
84+
4985
### Unsupported Features ( to be implemented in future versions )
5086

51-
- Notifications
5287
- Log Level
5388
- Resource subscriptions
5489
- Completions
55-
- Complete Streamable HTTP implementation with streaming responses
5690

5791
### Usage
5892

lib/mcp/methods.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ module Methods
2121

2222
SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
2323

24+
# Notification methods
25+
NOTIFICATIONS_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"
26+
NOTIFICATIONS_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"
27+
NOTIFICATIONS_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"
28+
2429
class MissingRequiredCapabilityError < StandardError
2530
attr_reader :method
2631
attr_reader :capability

lib/mcp/server.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,30 @@ def define_prompt(name: nil, description: nil, arguments: [], &block)
9898
@prompts[prompt.name_value] = prompt
9999
end
100100

101+
def notify_tools_list_changed
102+
return unless @transport
103+
104+
@transport.send_notification(Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED)
105+
rescue => e
106+
report_exception(e, { notification: "tools_list_changed" })
107+
end
108+
109+
def notify_prompts_list_changed
110+
return unless @transport
111+
112+
@transport.send_notification(Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED)
113+
rescue => e
114+
report_exception(e, { notification: "prompts_list_changed" })
115+
end
116+
117+
def notify_resources_list_changed
118+
return unless @transport
119+
120+
@transport.send_notification(Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED)
121+
rescue => e
122+
report_exception(e, { notification: "resources_list_changed" })
123+
end
124+
101125
def resources_list_handler(&block)
102126
@capabilities.support_resources
103127
@handlers[Methods::RESOURCES_LIST] = block

lib/mcp/server/transports/stdio_transport.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@ def send_response(message)
4343
$stdout.flush
4444
end
4545

46-
def send_response(message)
47-
json_message = message.is_a?(String) ? message : JSON.generate(message)
48-
$stdout.puts(json_message)
49-
$stdout.flush
46+
def send_notification(method, params = nil)
47+
notification = {
48+
jsonrpc: "2.0",
49+
method: method,
50+
}
51+
notification[:params] = params if params
52+
53+
send_response(notification)
54+
true
55+
rescue => e
56+
MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
57+
false
5058
end
5159
end
5260
end
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "json"
5+
require "mcp/server"
6+
require "mcp/server/transports/stdio_transport"
7+
8+
module MCP
9+
class Server
10+
module Transports
11+
class StdioNotificationIntegrationTest < ActiveSupport::TestCase
12+
class MockIO
13+
attr_reader :output
14+
15+
def initialize
16+
@output = []
17+
@closed = false
18+
end
19+
20+
def puts(message)
21+
@output << message
22+
end
23+
24+
def write(message)
25+
@output << message
26+
message.length
27+
end
28+
29+
def gets
30+
nil # Simulate end of input
31+
end
32+
33+
def set_encoding(encoding)
34+
# Mock implementation
35+
end
36+
37+
def flush
38+
# Mock implementation
39+
end
40+
41+
def close
42+
@closed = true
43+
end
44+
45+
def closed?
46+
@closed
47+
end
48+
end
49+
50+
setup do
51+
@original_stdout = $stdout
52+
@original_stdin = $stdin
53+
54+
@mock_stdout = MockIO.new
55+
@mock_stdin = MockIO.new
56+
57+
$stdout = @mock_stdout
58+
$stdin = @mock_stdin
59+
60+
@server = Server.new(
61+
name: "test_server",
62+
tools: [],
63+
prompts: [],
64+
resources: [],
65+
)
66+
@transport = StdioTransport.new(@server)
67+
@server.transport = @transport
68+
end
69+
70+
teardown do
71+
$stdout = @original_stdout
72+
$stdin = @original_stdin
73+
end
74+
75+
test "server notification methods send JSON-RPC notifications through StdioTransport" do
76+
# Test tools notification
77+
@server.notify_tools_list_changed
78+
79+
# Test prompts notification
80+
@server.notify_prompts_list_changed
81+
82+
# Test resources notification
83+
@server.notify_resources_list_changed
84+
85+
# Check the notifications were sent
86+
assert_equal 3, @mock_stdout.output.size
87+
88+
# Parse and verify each notification
89+
notifications = @mock_stdout.output.map { |msg| JSON.parse(msg) }
90+
91+
assert_equal "2.0", notifications[0]["jsonrpc"]
92+
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0]["method"]
93+
assert_nil notifications[0]["params"]
94+
95+
assert_equal "2.0", notifications[1]["jsonrpc"]
96+
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1]["method"]
97+
assert_nil notifications[1]["params"]
98+
99+
assert_equal "2.0", notifications[2]["jsonrpc"]
100+
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2]["method"]
101+
assert_nil notifications[2]["params"]
102+
end
103+
104+
test "notifications include params when provided" do
105+
# Test the transport's send_notification directly with params
106+
result = @transport.send_notification("test/notification", { data: "test_value" })
107+
108+
assert result
109+
assert_equal 1, @mock_stdout.output.size
110+
111+
notification = JSON.parse(@mock_stdout.output.first)
112+
assert_equal "2.0", notification["jsonrpc"]
113+
assert_equal "test/notification", notification["method"]
114+
assert_equal({ "data" => "test_value" }, notification["params"])
115+
end
116+
117+
test "server continues to work when stdout is closed" do
118+
# Close stdout
119+
@mock_stdout.close
120+
121+
# Server notifications should not raise errors
122+
assert_nothing_raised do
123+
@server.notify_tools_list_changed
124+
@server.notify_prompts_list_changed
125+
@server.notify_resources_list_changed
126+
end
127+
end
128+
129+
test "notifications work with dynamic tool additions" do
130+
# Define a new tool
131+
@server.define_tool(
132+
name: "dynamic_tool",
133+
description: "A dynamically added tool",
134+
) do |**args|
135+
{ result: "success" }
136+
end
137+
138+
# Clear previous output
139+
@mock_stdout.output.clear
140+
141+
# Manually trigger notification
142+
@server.notify_tools_list_changed
143+
144+
# Check the notification was sent
145+
assert_equal 1, @mock_stdout.output.size
146+
147+
notification = JSON.parse(@mock_stdout.output.first)
148+
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notification["method"]
149+
150+
# Verify the tool was added to the server
151+
assert @server.tools.key?("dynamic_tool")
152+
end
153+
154+
test "notifications are properly formatted JSON-RPC 2.0 messages" do
155+
# Send a notification
156+
@server.notify_prompts_list_changed
157+
158+
# Verify format
159+
assert_equal 1, @mock_stdout.output.size
160+
output = @mock_stdout.output.first
161+
162+
# Should be valid JSON
163+
notification = JSON.parse(output)
164+
165+
# Should have required JSON-RPC 2.0 fields
166+
assert_equal "2.0", notification["jsonrpc"]
167+
assert notification.key?("method")
168+
refute notification.key?("id") # Notifications should not have an id
169+
170+
# Method should be the expected notification type
171+
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notification["method"]
172+
end
173+
174+
test "multiple notifications are sent as separate JSON messages" do
175+
# Send multiple notifications rapidly
176+
5.times do
177+
@server.notify_tools_list_changed
178+
end
179+
180+
# Each should be a separate JSON message
181+
assert_equal 5, @mock_stdout.output.size
182+
183+
# All should be parseable as JSON
184+
@mock_stdout.output.each do |msg|
185+
notification = JSON.parse(msg)
186+
assert_equal "2.0", notification["jsonrpc"]
187+
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notification["method"]
188+
end
189+
end
190+
191+
test "transport handles errors gracefully" do
192+
# Create a stdout that raises errors
193+
error_stdout = Class.new(MockIO) do
194+
def puts(message)
195+
raise IOError, "Simulated IO error"
196+
end
197+
end.new
198+
199+
$stdout = error_stdout
200+
201+
# Notification should return false but not raise
202+
result = @transport.send_notification("test/notification")
203+
refute result
204+
end
205+
206+
test "server notification flow works end-to-end with StdioTransport" do
207+
# This test verifies the complete integration from server to transport
208+
209+
# Start with no output
210+
assert_empty @mock_stdout.output
211+
212+
# Add a prompt and notify
213+
@server.define_prompt(
214+
name: "test_prompt",
215+
description: "Test prompt",
216+
) do |args, server_context:|
217+
MCP::PromptResponse.new(messages: [{ role: "user", content: "Test" }])
218+
end
219+
220+
# Manually trigger notification
221+
@server.notify_prompts_list_changed
222+
223+
# Verify notification was sent
224+
assert_equal 1, @mock_stdout.output.size
225+
notification = JSON.parse(@mock_stdout.output.first)
226+
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notification["method"]
227+
228+
# Add a resource and notify
229+
@server.resources = [
230+
MCP::Resource.new(
231+
uri: "test://resource",
232+
name: "Test Resource",
233+
description: "A test resource",
234+
mime_type: "text/plain",
235+
),
236+
]
237+
238+
# Manually trigger notification
239+
@server.notify_resources_list_changed
240+
241+
# Verify both notifications were sent
242+
assert_equal 2, @mock_stdout.output.size
243+
second_notification = JSON.parse(@mock_stdout.output.last)
244+
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, second_notification["method"]
245+
end
246+
end
247+
end
248+
end
249+
end

0 commit comments

Comments
 (0)