mcp 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6a956181733036c09b8431db1d1da47e3757165123efd5d5dab5fe1cc522e6a
4
- data.tar.gz: fc611e55e4f88e9ca61ad64d4d2578c6e5a9680ceb4d667337787098ca59bbde
3
+ metadata.gz: fd749db825bb7586ca38768025dbd6c4e6e9e9aa5805666a7d9ee4b5f54a59f3
4
+ data.tar.gz: a7dbc93581cfda1a22a6740f33047586902efcc30153a1ebda836a3623d267b0
5
5
  SHA512:
6
- metadata.gz: '010991cbd4a1b18214d12d8ce3cb09b3658cba4a1cf72c7475ff76688fe87eef4137bd76eb5ebd7b15309e1aec7ed84909125858accd474f052e1a915ec37ad8'
7
- data.tar.gz: 5b9b2994a4fb3769bda007a39ce0cf02c2a9b4fbeeecb48dc74aa4ca66e4d6e066c545f5d473b339561af06cf14f8e546a6e87a92d4d9c0939fe90858cddb3a8
6
+ metadata.gz: f9c6a4cc9f66449020c13220b5bb3d8c17cf2de3f6f40ea685eb3e56a47fc706c0d43514985914259c3e663877e9ef5b9396ed08200b8f73de0dea35f608e1a5
7
+ data.tar.gz: 8a2976e9a5870aa4ce6404fd8123e67522dc2eb86804676c1feb574238315a69f2fb691acd3aabe22197ec77afe7c129c8225a6c318f8932e8fcb70ef3c7c595
data/README.md CHANGED
@@ -38,7 +38,9 @@ It implements the Model Context Protocol specification, handling model context r
38
38
  - Supports resource registration and retrieval
39
39
  - Supports stdio & Streamable HTTP (including SSE) transports
40
40
  - Supports notifications for list changes (tools, prompts, resources)
41
+ - Supports roots (server-to-client filesystem boundary queries)
41
42
  - Supports sampling (server-to-client LLM completion requests)
43
+ - Supports cursor-based pagination for list operations
42
44
 
43
45
  ### Supported Methods
44
46
 
@@ -51,7 +53,10 @@ It implements the Model Context Protocol specification, handling model context r
51
53
  - `resources/list` - Lists all registered resources and their schemas
52
54
  - `resources/read` - Retrieves a specific resource by name
53
55
  - `resources/templates/list` - Lists all registered resource templates and their schemas
56
+ - `resources/subscribe` - Subscribes to updates for a specific resource
57
+ - `resources/unsubscribe` - Unsubscribes from updates for a specific resource
54
58
  - `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
59
+ - `roots/list` - Requests filesystem roots from the client (server-to-client)
55
60
  - `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
56
61
  - `elicitation/create` - Requests user input from the client (server-to-client)
57
62
 
@@ -891,6 +896,108 @@ server = MCP::Server.new(
891
896
  )
892
897
  ```
893
898
 
899
+ ### Roots
900
+
901
+ The Model Context Protocol allows servers to request filesystem roots from clients through the `roots/list` method.
902
+ Roots define the boundaries of where a server can operate, providing a list of directories and files the client has made available.
903
+
904
+ **Key Concepts:**
905
+
906
+ - **Server-to-Client Request**: Like sampling, roots listing is initiated by the server
907
+ - **Client Capability**: Clients must declare `roots` capability during initialization
908
+ - **Change Notifications**: Clients that support `roots.listChanged` send `notifications/roots/list_changed` when roots change
909
+
910
+ **Using Roots in Tools:**
911
+
912
+ Tools that accept a `server_context:` parameter can call `list_roots` on it.
913
+ The request is automatically routed to the correct client session:
914
+
915
+ ```ruby
916
+ class FileSearchTool < MCP::Tool
917
+ description "Search files within the client's project roots"
918
+ input_schema(
919
+ properties: {
920
+ query: { type: "string" }
921
+ },
922
+ required: ["query"]
923
+ )
924
+
925
+ def self.call(query:, server_context:)
926
+ roots = server_context.list_roots
927
+ root_uris = roots[:roots].map { |root| root[:uri] }
928
+
929
+ MCP::Tool::Response.new([{
930
+ type: "text",
931
+ text: "Searching in roots: #{root_uris.join(", ")}"
932
+ }])
933
+ end
934
+ end
935
+ ```
936
+
937
+ Result contains an array of root objects:
938
+
939
+ ```ruby
940
+ {
941
+ roots: [
942
+ { uri: "file:///home/user/projects/myproject", name: "My Project" },
943
+ { uri: "file:///home/user/repos/backend", name: "Backend Repository" }
944
+ ]
945
+ }
946
+ ```
947
+
948
+ **Handling Root Changes:**
949
+
950
+ Register a callback to be notified when the client's roots change:
951
+
952
+ ```ruby
953
+ server.roots_list_changed_handler do
954
+ puts "Client's roots have changed, tools will see updated roots on next call."
955
+ end
956
+ ```
957
+
958
+ **Error Handling:**
959
+
960
+ - Raises `RuntimeError` if client does not support `roots` capability
961
+ - Raises `StandardError` if client returns an error response
962
+
963
+ ### Resource Subscriptions
964
+
965
+ Resource subscriptions allow clients to monitor specific resources for changes.
966
+ When a subscribed resource is updated, the server sends a notification to the client.
967
+
968
+ The SDK does not track subscription state internally.
969
+ Server developers register handlers and manage their own subscription state.
970
+ Three methods are provided:
971
+
972
+ - `Server#resources_subscribe_handler` - registers a handler for `resources/subscribe` requests
973
+ - `Server#resources_unsubscribe_handler` - registers a handler for `resources/unsubscribe` requests
974
+ - `ServerContext#notify_resources_updated` - sends a `notifications/resources/updated` notification to the subscribing client
975
+
976
+ ```ruby
977
+ subscribed_uris = Set.new
978
+
979
+ server = MCP::Server.new(
980
+ name: "my_server",
981
+ resources: [my_resource],
982
+ capabilities: { resources: { subscribe: true } },
983
+ )
984
+
985
+ server.resources_subscribe_handler do |params|
986
+ subscribed_uris.add(params[:uri].to_s)
987
+ end
988
+
989
+ server.resources_unsubscribe_handler do |params|
990
+ subscribed_uris.delete(params[:uri].to_s)
991
+ end
992
+
993
+ server.define_tool(name: "update_resource") do |server_context:, **args|
994
+ if subscribed_uris.include?("test://my-resource")
995
+ server_context.notify_resources_updated(uri: "test://my-resource")
996
+ end
997
+ MCP::Tool::Response.new([MCP::Content::Text.new("Resource updated").to_h])
998
+ end
999
+ ```
1000
+
894
1001
  ### Sampling
895
1002
 
896
1003
  The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method.
@@ -992,6 +1099,33 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
992
1099
  - `notifications/progress`
993
1100
  - `notifications/message`
994
1101
 
1102
+ ### Ping
1103
+
1104
+ The MCP Ruby SDK supports the
1105
+ [MCP `ping` utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping),
1106
+ which allows either side of the connection to verify that the peer is still responsive.
1107
+ A `ping` request has no parameters, and the receiver MUST respond promptly with an empty result.
1108
+
1109
+ #### Server-Side
1110
+
1111
+ Servers respond to incoming `ping` requests automatically - no setup is required.
1112
+ Any `MCP::Server` instance replies with an empty result.
1113
+
1114
+ #### Client-Side
1115
+
1116
+ `MCP::Client` exposes `ping` to send a ping to the server:
1117
+
1118
+ ```ruby
1119
+ client = MCP::Client.new(transport: transport)
1120
+ client.ping # => {} on success
1121
+ ```
1122
+
1123
+ `#ping` raises `MCP::Client::ServerError` when the server returns a JSON-RPC error.
1124
+ It raises `MCP::Client::ValidationError` when the response `result` is missing or
1125
+ is not a Hash (matching the spec requirement that `result` be an object).
1126
+ Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
1127
+ propagate as exceptions raised by the transport layer.
1128
+
995
1129
  ### Progress
996
1130
 
997
1131
  The MCP Ruby SDK supports progress tracking for long-running tool operations,
@@ -1291,6 +1425,22 @@ Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`
1291
1425
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
1292
1426
  ```
1293
1427
 
1428
+ You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
1429
+ Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
1430
+
1431
+ ```ruby
1432
+ # JSON response mode
1433
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true)
1434
+ ```
1435
+
1436
+ In JSON response mode, the POST response is a single JSON object, so server-to-client messages
1437
+ that need to arrive during request processing are not supported:
1438
+ request-scoped notifications (`progress`, `log`) are silently dropped, and all server-to-client requests
1439
+ (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
1440
+ Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and
1441
+ broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream.
1442
+ This mode is suitable for simple tool servers that do not need server-initiated requests.
1443
+
1294
1444
  By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
1295
1445
  When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:
1296
1446
 
@@ -1299,6 +1449,90 @@ When configured, sessions that receive no HTTP requests for this duration are au
1299
1449
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
1300
1450
  ```
1301
1451
 
1452
+ ### Pagination
1453
+
1454
+ The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination)
1455
+ for list operations that may return large result sets. Pagination uses string cursor tokens carrying a zero-based offset,
1456
+ treated as opaque by clients: the server decides page size, and the client follows `nextCursor` until the server omits it.
1457
+
1458
+ Pagination applies to `tools/list`, `prompts/list`, `resources/list`, and `resources/templates/list`.
1459
+
1460
+ #### Server-Side: Enabling Pagination
1461
+
1462
+ Pass `page_size:` to `MCP::Server.new` to split list responses into pages. When `page_size` is omitted (the default),
1463
+ list responses contain all items in a single response, preserving the pre-pagination behavior.
1464
+
1465
+ ```ruby
1466
+ server = MCP::Server.new(
1467
+ name: "my_server",
1468
+ tools: tools,
1469
+ page_size: 50,
1470
+ )
1471
+ ```
1472
+
1473
+ When `page_size` is set, list responses include a `nextCursor` field whenever more pages are available:
1474
+
1475
+ ```json
1476
+ {
1477
+ "jsonrpc": "2.0",
1478
+ "id": 1,
1479
+ "result": {
1480
+ "tools": [
1481
+ { "name": "example_tool" }
1482
+ ],
1483
+ "nextCursor": "50"
1484
+ }
1485
+ }
1486
+ ```
1487
+
1488
+ Invalid cursors (e.g. non-numeric, negative, or out-of-range) are rejected with JSON-RPC error code `-32602 (Invalid params)` per the MCP specification.
1489
+
1490
+ #### Client-Side: Iterating Pages
1491
+
1492
+ `MCP::Client` exposes `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
1493
+ **Each call issues exactly one `*/list` JSON-RPC request and returns exactly one page** — not the full collection.
1494
+ The returned result object (`MCP::Client::ListToolsResult` etc.) exposes the page items and the next cursor as method accessors:
1495
+
1496
+ ```ruby
1497
+ client = MCP::Client.new(transport: transport)
1498
+
1499
+ cursor = nil
1500
+ loop do
1501
+ page = client.list_tools(cursor: cursor)
1502
+ page.tools.each { |tool| process(tool) }
1503
+ cursor = page.next_cursor
1504
+ break unless cursor
1505
+ end
1506
+ ```
1507
+
1508
+ The same pattern applies to `list_prompts` (`page.prompts`), `list_resources` (`page.resources`), and
1509
+ `list_resource_templates` (`page.resource_templates`). `next_cursor` is `nil` on the final page.
1510
+
1511
+ Because a single call returns a single page, how many items come back depends on the server's `page_size` configuration:
1512
+
1513
+ | Server `page_size` | `client.list_tools(cursor: nil)` |
1514
+ |--------------------|---------------------------------------------------------------------|
1515
+ | Not set (default) | Returns every item in one response. `next_cursor` is `nil`. |
1516
+ | Set to `N` | Returns the first `N` items. `next_cursor` is set for continuation. |
1517
+
1518
+ If your application needs the complete collection regardless of how the server is configured, either loop on
1519
+ `next_cursor` as shown above, or use the whole-collection methods described below.
1520
+
1521
+ #### Fetching the Complete Collection
1522
+
1523
+ `client.tools`, `client.resources`, `client.resource_templates`, and `client.prompts` auto-iterate
1524
+ through all pages and return a plain array of items, guaranteeing the full collection regardless
1525
+ of the server's `page_size` setting. When a server paginates, they issue multiple JSON-RPC round
1526
+ trips per call and break out of the pagination loop if the server returns the same `nextCursor`
1527
+ twice in a row as a safety measure.
1528
+
1529
+ ```ruby
1530
+ tools = client.tools # => Array<MCP::Client::Tool> of every tool on the server.
1531
+ ```
1532
+
1533
+ Use these when you want the complete list; use `list_tools(cursor:)` etc. when you need
1534
+ fine-grained iteration (e.g. to stream-process pages without loading everything into memory).
1535
+
1302
1536
  ### Advanced
1303
1537
 
1304
1538
  #### Custom Methods
@@ -1352,16 +1586,13 @@ end
1352
1586
  - Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method
1353
1587
  - Supports the same exception reporting and instrumentation as standard methods
1354
1588
 
1355
- ### Unsupported Features (to be implemented in future versions)
1356
-
1357
- - Resource subscriptions
1358
-
1359
1589
  ## Building an MCP Client
1360
1590
 
1361
1591
  The `MCP::Client` class provides an interface for interacting with MCP servers.
1362
1592
 
1363
1593
  This class supports:
1364
1594
 
1595
+ - Liveness check via the `ping` method (`MCP::Client#ping`)
1365
1596
  - Tool listing via the `tools/list` method (`MCP::Client#tools`)
1366
1597
  - Tool invocation via the `tools/call` method (`MCP::Client#call_tools`)
1367
1598
  - Resource listing via the `resources/list` method (`MCP::Client#resources`)
@@ -1448,11 +1679,12 @@ The stdio transport automatically handles:
1448
1679
 
1449
1680
  Use the `MCP::Client::HTTP` transport to interact with MCP servers using simple HTTP requests.
1450
1681
 
1451
- You'll need to add `faraday` as a dependency in order to use the HTTP transport layer:
1682
+ You'll need to add `faraday` as a dependency in order to use the HTTP transport layer. Add `event_stream_parser` as well if the server uses SSE (`text/event-stream`) responses:
1452
1683
 
1453
1684
  ```ruby
1454
1685
  gem 'mcp'
1455
1686
  gem 'faraday', '>= 2.0'
1687
+ gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
1456
1688
  ```
1457
1689
 
1458
1690
  Example usage:
@@ -1,25 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../methods"
4
+
3
5
  module MCP
4
6
  class Client
7
+ # TODO: HTTP GET for SSE streaming is not yet implemented.
8
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server
9
+ # TODO: Resumability and redelivery with Last-Event-ID is not yet implemented.
10
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery
5
11
  class HTTP
6
12
  ACCEPT_HEADER = "application/json, text/event-stream"
13
+ SESSION_ID_HEADER = "Mcp-Session-Id"
14
+ PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
7
15
 
8
- attr_reader :url
16
+ attr_reader :url, :session_id, :protocol_version
9
17
 
10
18
  def initialize(url:, headers: {}, &block)
11
19
  @url = url
12
20
  @headers = headers
13
21
  @faraday_customizer = block
22
+ @session_id = nil
23
+ @protocol_version = nil
14
24
  end
15
25
 
26
+ # Sends a JSON-RPC request and returns the parsed response body.
27
+ # After a successful `initialize` handshake, the session ID and protocol
28
+ # version returned by the server are captured and automatically included
29
+ # on subsequent requests.
16
30
  def send_request(request:)
17
31
  method = request[:method] || request["method"]
18
32
  params = request[:params] || request["params"]
19
33
 
20
- response = client.post("", request)
21
- validate_response_content_type!(response, method, params)
22
- response.body
34
+ response = client.post("", request, session_headers)
35
+ body = parse_response_body(response, method, params)
36
+
37
+ capture_session_info(method, response, body)
38
+
39
+ body
23
40
  rescue Faraday::BadRequestError => e
24
41
  raise RequestHandlerError.new(
25
42
  "The #{method} request is invalid",
@@ -42,12 +59,25 @@ module MCP
42
59
  original_error: e,
43
60
  )
44
61
  rescue Faraday::ResourceNotFound => e
45
- raise RequestHandlerError.new(
46
- "The #{method} request is not found",
47
- { method: method, params: params },
48
- error_type: :not_found,
49
- original_error: e,
50
- )
62
+ # Per spec, 404 is the session-expired signal only when the request
63
+ # actually carried an `Mcp-Session-Id`. A 404 without a session attached
64
+ # (e.g. wrong URL or a stateless server) surfaces as a generic not-found.
65
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
66
+ if @session_id
67
+ clear_session
68
+ raise SessionExpiredError.new(
69
+ "The #{method} request is not found",
70
+ { method: method, params: params },
71
+ original_error: e,
72
+ )
73
+ else
74
+ raise RequestHandlerError.new(
75
+ "The #{method} request is not found",
76
+ { method: method, params: params },
77
+ error_type: :not_found,
78
+ original_error: e,
79
+ )
80
+ end
51
81
  rescue Faraday::UnprocessableEntityError => e
52
82
  raise RequestHandlerError.new(
53
83
  "The #{method} request is unprocessable",
@@ -64,6 +94,28 @@ module MCP
64
94
  )
65
95
  end
66
96
 
97
+ # Terminates the session by sending an HTTP DELETE to the MCP endpoint
98
+ # with the current `Mcp-Session-Id` header, and clears locally tracked
99
+ # session state afterward. No-op when no session has been established.
100
+ #
101
+ # Per spec, the server MAY respond with HTTP 405 Method Not Allowed when
102
+ # it does not support client-initiated termination, and returns 404 for
103
+ # a session it has already terminated. Both mean the session is gone —
104
+ # the desired end state. Other errors surface to the caller; local
105
+ # session state is cleared either way.
106
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
107
+ def close
108
+ return unless @session_id
109
+
110
+ begin
111
+ client.delete("", nil, session_headers)
112
+ rescue Faraday::ClientError => e
113
+ raise unless [404, 405].include?(e.response&.dig(:status))
114
+ ensure
115
+ clear_session
116
+ end
117
+ end
118
+
67
119
  private
68
120
 
69
121
  attr_reader :headers
@@ -84,6 +136,31 @@ module MCP
84
136
  end
85
137
  end
86
138
 
139
+ # Per spec, the client MUST include `MCP-Session-Id` (when the server assigned one)
140
+ # and `MCP-Protocol-Version` on all requests after `initialize`.
141
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
142
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header
143
+ def session_headers
144
+ request_headers = {}
145
+ request_headers[SESSION_ID_HEADER] = @session_id if @session_id
146
+ request_headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version
147
+ request_headers
148
+ end
149
+
150
+ def capture_session_info(method, response, body)
151
+ return unless method.to_s == Methods::INITIALIZE
152
+
153
+ # Faraday normalizes header names to lowercase.
154
+ session_id = response.headers[SESSION_ID_HEADER.downcase]
155
+ @session_id ||= session_id unless session_id.to_s.empty?
156
+ @protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
157
+ end
158
+
159
+ def clear_session
160
+ @session_id = nil
161
+ @protocol_version = nil
162
+ end
163
+
87
164
  def require_faraday!
88
165
  require "faraday"
89
166
  rescue LoadError
@@ -92,14 +169,56 @@ module MCP
92
169
  "See https://rubygems.org/gems/faraday for more details."
93
170
  end
94
171
 
95
- def validate_response_content_type!(response, method, params)
172
+ def require_event_stream_parser!
173
+ require "event_stream_parser"
174
+ rescue LoadError
175
+ raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
176
+ "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
177
+ "See https://rubygems.org/gems/event_stream_parser for more details."
178
+ end
179
+
180
+ def parse_response_body(response, method, params)
181
+ # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
182
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
183
+ return if response.status == 202
184
+
96
185
  content_type = response.headers["Content-Type"]
97
- return if content_type&.include?("application/json")
186
+
187
+ if content_type&.include?("text/event-stream")
188
+ parse_sse_response(response.body, method, params)
189
+ elsif content_type&.include?("application/json")
190
+ response.body
191
+ else
192
+ raise RequestHandlerError.new(
193
+ "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
194
+ { method: method, params: params },
195
+ error_type: :unsupported_media_type,
196
+ )
197
+ end
198
+ end
199
+
200
+ def parse_sse_response(body, method, params)
201
+ require_event_stream_parser!
202
+
203
+ json_rpc_response = nil
204
+ parser = EventStreamParser::Parser.new
205
+ parser.feed(body.to_s) do |_type, data, _id|
206
+ next if data.empty?
207
+
208
+ begin
209
+ parsed = JSON.parse(data)
210
+ json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
211
+ rescue JSON::ParserError
212
+ next
213
+ end
214
+ end
215
+
216
+ return json_rpc_response if json_rpc_response
98
217
 
99
218
  raise RequestHandlerError.new(
100
- "Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.",
219
+ "No valid JSON-RPC response found in SSE stream",
101
220
  { method: method, params: params },
102
- error_type: :unsupported_media_type,
221
+ error_type: :parse_error,
103
222
  )
104
223
  end
105
224
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
6
+ # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
7
+ # and an optional `meta` hash mirroring the MCP `_meta` response field.
8
+ ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
9
+ ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
10
+ ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
11
+ ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true)
12
+ end
13
+ end
data/lib/mcp/client.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "client/stdio"
4
4
  require_relative "client/http"
5
+ require_relative "client/paginated_result"
5
6
  require_relative "client/tool"
6
7
 
7
8
  module MCP
@@ -27,6 +28,21 @@ module MCP
27
28
  end
28
29
  end
29
30
 
31
+ # Raised when a server response fails client-side validation, e.g., a success response
32
+ # whose `result` field is missing or has the wrong type. This is distinct from a
33
+ # server-returned JSON-RPC error, which is raised as `ServerError`.
34
+ class ValidationError < StandardError; end
35
+
36
+ # Raised when the server responds 404 to a request containing a session ID,
37
+ # indicating the session has expired. Inherits from `RequestHandlerError` for
38
+ # backward compatibility with callers that rescue the generic error. Per spec,
39
+ # clients MUST start a new session with a fresh `initialize` request in response.
40
+ class SessionExpiredError < RequestHandlerError
41
+ def initialize(message, request, original_error: nil)
42
+ super(message, request, error_type: :not_found, original_error: original_error)
43
+ end
44
+ end
45
+
30
46
  # Initializes a new MCP::Client instance.
31
47
  #
32
48
  # @param transport [Object] The transport object to use for communication with the server.
@@ -43,8 +59,41 @@ module MCP
43
59
  # So keeping it public
44
60
  attr_reader :transport
45
61
 
46
- # Returns the list of tools available from the server.
47
- # Each call will make a new request – the result is not cached.
62
+ # Returns a single page of tools from the server.
63
+ #
64
+ # @param cursor [String, nil] Cursor from a previous page response.
65
+ # @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
66
+ # and `next_cursor` (String or nil).
67
+ #
68
+ # @example Iterate all pages
69
+ # cursor = nil
70
+ # loop do
71
+ # page = client.list_tools(cursor: cursor)
72
+ # page.tools.each { |tool| puts tool.name }
73
+ # cursor = page.next_cursor
74
+ # break unless cursor
75
+ # end
76
+ def list_tools(cursor: nil)
77
+ params = cursor ? { cursor: cursor } : nil
78
+ response = request(method: "tools/list", params: params)
79
+ result = response["result"] || {}
80
+
81
+ tools = (result["tools"] || []).map do |tool|
82
+ Tool.new(
83
+ name: tool["name"],
84
+ description: tool["description"],
85
+ input_schema: tool["inputSchema"],
86
+ )
87
+ end
88
+
89
+ ListToolsResult.new(tools: tools, next_cursor: result["nextCursor"], meta: result["_meta"])
90
+ end
91
+
92
+ # Returns every tool available on the server. Iterates through all pages automatically
93
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
94
+ # Use {#list_tools} when you need fine-grained cursor control.
95
+ #
96
+ # Each call will make a new request - the result is not cached.
48
97
  #
49
98
  # @return [Array<MCP::Client::Tool>] An array of available tools.
50
99
  #
@@ -54,45 +103,151 @@ module MCP
54
103
  # puts tool.name
55
104
  # end
56
105
  def tools
57
- response = request(method: "tools/list")
106
+ # TODO: consider renaming to `list_all_tools`.
107
+ all_tools = []
108
+ seen = Set.new
109
+ cursor = nil
58
110
 
59
- response.dig("result", "tools")&.map do |tool|
60
- Tool.new(
61
- name: tool["name"],
62
- description: tool["description"],
63
- input_schema: tool["inputSchema"],
64
- )
65
- end || []
111
+ loop do
112
+ page = list_tools(cursor: cursor)
113
+ all_tools.concat(page.tools)
114
+ next_cursor = page.next_cursor
115
+ break if next_cursor.nil? || seen.include?(next_cursor)
116
+
117
+ seen << next_cursor
118
+ cursor = next_cursor
119
+ end
120
+
121
+ all_tools
66
122
  end
67
123
 
68
- # Returns the list of resources available from the server.
69
- # Each call will make a new request – the result is not cached.
124
+ # Returns a single page of resources from the server.
125
+ #
126
+ # @param cursor [String, nil] Cursor from a previous page response.
127
+ # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
128
+ # and `next_cursor` (String or nil).
129
+ def list_resources(cursor: nil)
130
+ params = cursor ? { cursor: cursor } : nil
131
+ response = request(method: "resources/list", params: params)
132
+ result = response["result"] || {}
133
+
134
+ ListResourcesResult.new(
135
+ resources: result["resources"] || [],
136
+ next_cursor: result["nextCursor"],
137
+ meta: result["_meta"],
138
+ )
139
+ end
140
+
141
+ # Returns every resource available on the server. Iterates through all pages automatically
142
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
143
+ # Use {#list_resources} when you need fine-grained cursor control.
144
+ #
145
+ # Each call will make a new request - the result is not cached.
70
146
  #
71
147
  # @return [Array<Hash>] An array of available resources.
72
148
  def resources
73
- response = request(method: "resources/list")
149
+ # TODO: consider renaming to `list_all_resources`.
150
+ all_resources = []
151
+ seen = Set.new
152
+ cursor = nil
153
+
154
+ loop do
155
+ page = list_resources(cursor: cursor)
156
+ all_resources.concat(page.resources)
157
+ next_cursor = page.next_cursor
158
+ break if next_cursor.nil? || seen.include?(next_cursor)
159
+
160
+ seen << next_cursor
161
+ cursor = next_cursor
162
+ end
163
+
164
+ all_resources
165
+ end
74
166
 
75
- response.dig("result", "resources") || []
167
+ # Returns a single page of resource templates from the server.
168
+ #
169
+ # @param cursor [String, nil] Cursor from a previous page response.
170
+ # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
171
+ # (Array<Hash>) and `next_cursor` (String or nil).
172
+ def list_resource_templates(cursor: nil)
173
+ params = cursor ? { cursor: cursor } : nil
174
+ response = request(method: "resources/templates/list", params: params)
175
+ result = response["result"] || {}
176
+
177
+ ListResourceTemplatesResult.new(
178
+ resource_templates: result["resourceTemplates"] || [],
179
+ next_cursor: result["nextCursor"],
180
+ meta: result["_meta"],
181
+ )
76
182
  end
77
183
 
78
- # Returns the list of resource templates available from the server.
79
- # Each call will make a new request the result is not cached.
184
+ # Returns every resource template available on the server. Iterates through all pages automatically
185
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
186
+ # Use {#list_resource_templates} when you need fine-grained cursor control.
187
+ #
188
+ # Each call will make a new request - the result is not cached.
80
189
  #
81
190
  # @return [Array<Hash>] An array of available resource templates.
82
191
  def resource_templates
83
- response = request(method: "resources/templates/list")
192
+ # TODO: consider renaming to `list_all_resource_templates`.
193
+ all_templates = []
194
+ seen = Set.new
195
+ cursor = nil
196
+
197
+ loop do
198
+ page = list_resource_templates(cursor: cursor)
199
+ all_templates.concat(page.resource_templates)
200
+ next_cursor = page.next_cursor
201
+ break if next_cursor.nil? || seen.include?(next_cursor)
202
+
203
+ seen << next_cursor
204
+ cursor = next_cursor
205
+ end
206
+
207
+ all_templates
208
+ end
209
+
210
+ # Returns a single page of prompts from the server.
211
+ #
212
+ # @param cursor [String, nil] Cursor from a previous page response.
213
+ # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
214
+ # and `next_cursor` (String or nil).
215
+ def list_prompts(cursor: nil)
216
+ params = cursor ? { cursor: cursor } : nil
217
+ response = request(method: "prompts/list", params: params)
218
+ result = response["result"] || {}
84
219
 
85
- response.dig("result", "resourceTemplates") || []
220
+ ListPromptsResult.new(
221
+ prompts: result["prompts"] || [],
222
+ next_cursor: result["nextCursor"],
223
+ meta: result["_meta"],
224
+ )
86
225
  end
87
226
 
88
- # Returns the list of prompts available from the server.
89
- # Each call will make a new request the result is not cached.
227
+ # Returns every prompt available on the server. Iterates through all pages automatically
228
+ # when the server paginates, so the full collection is returned regardless of the server's `page_size` setting.
229
+ # Use {#list_prompts} when you need fine-grained cursor control.
230
+ #
231
+ # Each call will make a new request - the result is not cached.
90
232
  #
91
233
  # @return [Array<Hash>] An array of available prompts.
92
234
  def prompts
93
- response = request(method: "prompts/list")
235
+ # TODO: consider renaming to `list_all_prompts`.
236
+ all_prompts = []
237
+ seen = Set.new
238
+ cursor = nil
239
+
240
+ loop do
241
+ page = list_prompts(cursor: cursor)
242
+ all_prompts.concat(page.prompts)
243
+ next_cursor = page.next_cursor
244
+ break if next_cursor.nil? || seen.include?(next_cursor)
245
+
246
+ seen << next_cursor
247
+ cursor = next_cursor
248
+ end
94
249
 
95
- response.dig("result", "prompts") || []
250
+ all_prompts
96
251
  end
97
252
 
98
253
  # Calls a tool via the transport layer and returns the full response from the server.
@@ -163,6 +318,24 @@ module MCP
163
318
  response.dig("result", "completion") || { "values" => [], "hasMore" => false }
164
319
  end
165
320
 
321
+ # Sends a `ping` request to the server to verify the connection is alive.
322
+ # Per the MCP spec, the server responds with an empty result.
323
+ #
324
+ # @return [Hash] An empty hash on success.
325
+ # @raise [ServerError] If the server returns a JSON-RPC error.
326
+ # @raise [ValidationError] If the response `result` is missing or not a Hash.
327
+ #
328
+ # @example
329
+ # client.ping # => {}
330
+ #
331
+ # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
332
+ def ping
333
+ result = request(method: Methods::PING)["result"]
334
+ raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
335
+
336
+ result
337
+ end
338
+
166
339
  private
167
340
 
168
341
  def request(method:, params: nil)
data/lib/mcp/methods.rb CHANGED
@@ -73,14 +73,12 @@ module MCP
73
73
  require_capability!(method, capabilities, :completions)
74
74
  when ROOTS_LIST
75
75
  require_capability!(method, capabilities, :roots)
76
- when NOTIFICATIONS_ROOTS_LIST_CHANGED
77
- require_capability!(method, capabilities, :roots)
78
- require_capability!(method, capabilities, :roots, :listChanged)
79
76
  when SAMPLING_CREATE_MESSAGE
80
77
  require_capability!(method, capabilities, :sampling)
81
78
  when ELICITATION_CREATE
82
79
  require_capability!(method, capabilities, :elicitation)
83
- when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
80
+ when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
81
+ NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
84
82
  # No specific capability required.
85
83
  end
86
84
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ module Pagination
6
+ private
7
+
8
+ def cursor_from(request)
9
+ return if request.nil?
10
+
11
+ unless request.is_a?(Hash)
12
+ raise RequestHandlerError.new("Invalid params", request, error_type: :invalid_params)
13
+ end
14
+
15
+ request[:cursor]
16
+ end
17
+
18
+ def paginate(items, cursor:, page_size:, request:, &block)
19
+ start_index = 0
20
+
21
+ if cursor
22
+ unless cursor.is_a?(String)
23
+ raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params)
24
+ end
25
+
26
+ start_index = Integer(cursor, exception: false)
27
+ if start_index.nil? || start_index < 0 || start_index >= items.size
28
+ raise RequestHandlerError.new("Invalid cursor", request, error_type: :invalid_params)
29
+ end
30
+ end
31
+
32
+ end_index = page_size ? start_index + page_size : items.size
33
+ page = items[start_index...end_index]
34
+ page = page.map(&block) if block
35
+
36
+ result = { items: page }
37
+ result[:next_cursor] = end_index.to_s if end_index < items.size
38
+ result
39
+ end
40
+ end
41
+ end
42
+ end
@@ -22,13 +22,14 @@ module MCP
22
22
  "Connection" => "keep-alive",
23
23
  }.freeze
24
24
 
25
- def initialize(server, stateless: false, session_idle_timeout: nil)
25
+ def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
26
26
  super(server)
27
27
  # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
28
28
  @sessions = {}
29
29
  @mutex = Mutex.new
30
30
 
31
31
  @stateless = stateless
32
+ @enable_json_response = enable_json_response
32
33
  @session_idle_timeout = session_idle_timeout
33
34
  @pending_responses = {}
34
35
 
@@ -43,7 +44,8 @@ module MCP
43
44
  start_reaper_thread if @session_idle_timeout
44
45
  end
45
46
 
46
- REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
47
+ REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze
48
+ REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
47
49
  REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
48
50
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
49
51
  SESSION_REAP_INTERVAL = 60
@@ -94,6 +96,12 @@ module MCP
94
96
 
95
97
  result = @mutex.synchronize do
96
98
  if session_id
99
+ # JSON response mode returns a single JSON object as the POST response,
100
+ # so request-scoped notifications (e.g. progress, log) cannot be delivered
101
+ # alongside it. Session-scoped standalone notifications
102
+ # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
103
+ next false if @enable_json_response && related_request_id
104
+
97
105
  # Send to specific session
98
106
  if (session = @sessions[session_id])
99
107
  stream = active_stream(session, related_request_id: related_request_id)
@@ -172,6 +180,10 @@ module MCP
172
180
  raise "Stateless mode does not support server-to-client requests."
173
181
  end
174
182
 
183
+ if @enable_json_response
184
+ raise "JSON response mode does not support server-to-client requests."
185
+ end
186
+
175
187
  unless session_id
176
188
  raise "session_id is required for server-to-client requests."
177
189
  end
@@ -269,16 +281,17 @@ module MCP
269
281
  def send_to_stream(stream, data)
270
282
  message = data.is_a?(String) ? data : data.to_json
271
283
  stream.write("data: #{message}\n\n")
272
- stream.flush if stream.respond_to?(:flush)
284
+ stream.flush
273
285
  end
274
286
 
275
287
  def send_ping_to_stream(stream)
276
288
  stream.write(": ping #{Time.now.iso8601}\n\n")
277
- stream.flush if stream.respond_to?(:flush)
289
+ stream.flush
278
290
  end
279
291
 
280
292
  def handle_post(request)
281
- accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
293
+ required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
294
+ accept_error = validate_accept_header(request, required_types)
282
295
  return accept_error if accept_error
283
296
 
284
297
  content_type_error = validate_content_type(request)
@@ -519,7 +532,7 @@ module MCP
519
532
  end
520
533
  end
521
534
 
522
- if session_id && !@stateless
535
+ if session_id && !@stateless && !@enable_json_response
523
536
  handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
524
537
  else
525
538
  response = dispatch_handle_json(body_string, server_session)
data/lib/mcp/server.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "methods"
6
6
  require_relative "logging_message_notification"
7
7
  require_relative "progress"
8
8
  require_relative "server_context"
9
+ require_relative "server/pagination"
9
10
  require_relative "server/transports"
10
11
 
11
12
  module MCP
@@ -65,9 +66,10 @@ module MCP
65
66
  end
66
67
 
67
68
  include Instrumentation
69
+ include Pagination
68
70
 
69
71
  attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
70
- attr_reader :client_capabilities
72
+ attr_reader :page_size, :client_capabilities
71
73
 
72
74
  def initialize(
73
75
  description: nil,
@@ -84,6 +86,7 @@ module MCP
84
86
  server_context: nil,
85
87
  configuration: nil,
86
88
  capabilities: nil,
89
+ page_size: nil,
87
90
  transport: nil
88
91
  )
89
92
  @description = description
@@ -100,6 +103,7 @@ module MCP
100
103
  @resource_templates = resource_templates
101
104
  @resource_index = index_resources_by_uri(resources)
102
105
  @server_context = server_context
106
+ self.page_size = page_size
103
107
  @configuration = MCP.configuration.merge(configuration)
104
108
  @client = nil
105
109
 
@@ -113,6 +117,8 @@ module MCP
113
117
  Methods::RESOURCES_LIST => method(:list_resources),
114
118
  Methods::RESOURCES_READ => method(:read_resource_no_content),
115
119
  Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
120
+ Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
121
+ Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
116
122
  Methods::TOOLS_LIST => method(:list_tools),
117
123
  Methods::TOOLS_CALL => method(:call_tool),
118
124
  Methods::PROMPTS_LIST => method(:list_prompts),
@@ -121,12 +127,9 @@ module MCP
121
127
  Methods::PING => ->(_) { {} },
122
128
  Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
123
129
  Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
130
+ Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
124
131
  Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
125
132
  Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
126
-
127
- # No op handlers for currently unsupported methods
128
- Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
129
- Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
130
133
  }
131
134
  @transport = transport
132
135
  end
@@ -182,6 +185,14 @@ module MCP
182
185
  @handlers[method_name] = block
183
186
  end
184
187
 
188
+ def page_size=(page_size)
189
+ unless page_size.nil? || (page_size.is_a?(Integer) && page_size > 0)
190
+ raise ArgumentError, "page_size must be nil or a positive integer"
191
+ end
192
+
193
+ @page_size = page_size
194
+ end
195
+
185
196
  def notify_tools_list_changed
186
197
  return unless @transport
187
198
 
@@ -218,6 +229,14 @@ module MCP
218
229
  report_exception(e, { notification: "log_message" })
219
230
  end
220
231
 
232
+ # Sets a handler for `notifications/roots/list_changed` notifications.
233
+ # Called when a client notifies the server that its filesystem roots have changed.
234
+ #
235
+ # @yield [params] The notification params (typically `nil`).
236
+ def roots_list_changed_handler(&block)
237
+ @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
238
+ end
239
+
221
240
  # Sets a custom handler for `resources/read` requests.
222
241
  # The block receives the parsed request params and should return resource
223
242
  # contents. The return value is set as the `contents` field of the response.
@@ -237,6 +256,24 @@ module MCP
237
256
  @handlers[Methods::COMPLETION_COMPLETE] = block
238
257
  end
239
258
 
259
+ # Sets a custom handler for `resources/subscribe` requests.
260
+ # The block receives the parsed request params. The return value is
261
+ # ignored; the response is always an empty result `{}` per the MCP specification.
262
+ #
263
+ # @yield [params] The request params containing `:uri`.
264
+ def resources_subscribe_handler(&block)
265
+ @handlers[Methods::RESOURCES_SUBSCRIBE] = block
266
+ end
267
+
268
+ # Sets a custom handler for `resources/unsubscribe` requests.
269
+ # The block receives the parsed request params. The return value is
270
+ # ignored; the response is always an empty result `{}` per the MCP specification.
271
+ #
272
+ # @yield [params] The request params containing `:uri`.
273
+ def resources_unsubscribe_handler(&block)
274
+ @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
275
+ end
276
+
240
277
  def build_sampling_params(
241
278
  capabilities,
242
279
  messages:,
@@ -368,16 +405,11 @@ module MCP
368
405
  result = case method
369
406
  when Methods::INITIALIZE
370
407
  init(params, session: session)
371
- when Methods::TOOLS_LIST
372
- { tools: @handlers[Methods::TOOLS_LIST].call(params) }
373
- when Methods::PROMPTS_LIST
374
- { prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
375
- when Methods::RESOURCES_LIST
376
- { resources: @handlers[Methods::RESOURCES_LIST].call(params) }
377
408
  when Methods::RESOURCES_READ
378
409
  { contents: @handlers[Methods::RESOURCES_READ].call(params) }
379
- when Methods::RESOURCES_TEMPLATES_LIST
380
- { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
410
+ when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
411
+ @handlers[method].call(params)
412
+ {}
381
413
  when Methods::TOOLS_CALL
382
414
  call_tool(params, session: session, related_request_id: related_request_id)
383
415
  when Methods::COMPLETION_COMPLETE
@@ -479,7 +511,9 @@ module MCP
479
511
  end
480
512
 
481
513
  def list_tools(request)
482
- @tools.values.map(&:to_h)
514
+ page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
515
+
516
+ { tools: page[:items], nextCursor: page[:next_cursor] }.compact
483
517
  end
484
518
 
485
519
  def call_tool(request, session: nil, related_request_id: nil)
@@ -527,7 +561,9 @@ module MCP
527
561
  end
528
562
 
529
563
  def list_prompts(request)
530
- @prompts.values.map(&:to_h)
564
+ page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
565
+
566
+ { prompts: page[:items], nextCursor: page[:next_cursor] }.compact
531
567
  end
532
568
 
533
569
  def get_prompt(request)
@@ -547,7 +583,9 @@ module MCP
547
583
  end
548
584
 
549
585
  def list_resources(request)
550
- @resources.map(&:to_h)
586
+ page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
587
+
588
+ { resources: page[:items], nextCursor: page[:next_cursor] }.compact
551
589
  end
552
590
 
553
591
  # Server implementation should set `resources_read_handler` to override no-op default
@@ -557,7 +595,9 @@ module MCP
557
595
  end
558
596
 
559
597
  def list_resource_templates(request)
560
- @resource_templates.map(&:to_h)
598
+ page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
599
+
600
+ { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
561
601
  end
562
602
 
563
603
  def complete(params)
@@ -30,6 +30,24 @@ module MCP
30
30
  @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
31
31
  end
32
32
 
33
+ # Sends a resource updated notification scoped to the originating session.
34
+ #
35
+ # @param uri [String] The URI of the updated resource.
36
+ def notify_resources_updated(uri:)
37
+ return unless @notification_target
38
+
39
+ @notification_target.notify_resources_updated(uri: uri)
40
+ end
41
+
42
+ # Delegates to the session so the request is scoped to the originating client.
43
+ def list_roots
44
+ if @notification_target.respond_to?(:list_roots)
45
+ @notification_target.list_roots(related_request_id: @related_request_id)
46
+ else
47
+ raise NoMethodError, "undefined method 'list_roots' for #{self}"
48
+ end
49
+ end
50
+
33
51
  # Delegates to the session so the request is scoped to the originating client.
34
52
  # Falls back to `@context` (via `method_missing`) when `@notification_target`
35
53
  # does not support sampling.
@@ -41,6 +41,15 @@ module MCP
41
41
  @client_capabilities || @server.client_capabilities
42
42
  end
43
43
 
44
+ # Sends a `roots/list` request scoped to this session.
45
+ def list_roots(related_request_id: nil)
46
+ unless client_capabilities&.dig(:roots)
47
+ raise "Client does not support roots."
48
+ end
49
+
50
+ send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
51
+ end
52
+
44
53
  # Sends a `sampling/createMessage` request scoped to this session.
45
54
  def create_sampling_message(related_request_id: nil, **kwargs)
46
55
  params = @server.build_sampling_params(client_capabilities, **kwargs)
@@ -76,6 +85,13 @@ module MCP
76
85
  @server.report_exception(e, notification: "elicitation_complete")
77
86
  end
78
87
 
88
+ # Sends a resource updated notification to this session only.
89
+ def notify_resources_updated(uri:)
90
+ send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
91
+ rescue => e
92
+ @server.report_exception(e, notification: "resources_updated")
93
+ end
94
+
79
95
  # Sends a progress notification to this session only.
80
96
  def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
81
97
  params = {
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.13.0"
4
+ VERSION = "0.14.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -39,6 +39,7 @@ files:
39
39
  - lib/mcp/annotations.rb
40
40
  - lib/mcp/client.rb
41
41
  - lib/mcp/client/http.rb
42
+ - lib/mcp/client/paginated_result.rb
42
43
  - lib/mcp/client/stdio.rb
43
44
  - lib/mcp/client/tool.rb
44
45
  - lib/mcp/configuration.rb
@@ -58,6 +59,7 @@ files:
58
59
  - lib/mcp/resource_template.rb
59
60
  - lib/mcp/server.rb
60
61
  - lib/mcp/server/capabilities.rb
62
+ - lib/mcp/server/pagination.rb
61
63
  - lib/mcp/server/transports.rb
62
64
  - lib/mcp/server/transports/stdio_transport.rb
63
65
  - lib/mcp/server/transports/streamable_http_transport.rb
@@ -73,13 +75,13 @@ files:
73
75
  - lib/mcp/transport.rb
74
76
  - lib/mcp/transports/stdio.rb
75
77
  - lib/mcp/version.rb
76
- homepage: https://github.com/modelcontextprotocol/ruby-sdk
78
+ homepage: https://ruby.sdk.modelcontextprotocol.io
77
79
  licenses:
78
80
  - Apache-2.0
79
81
  metadata:
80
82
  allowed_push_host: https://rubygems.org
81
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.13.0
82
- homepage_uri: https://github.com/modelcontextprotocol/ruby-sdk
83
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.14.0
84
+ homepage_uri: https://ruby.sdk.modelcontextprotocol.io
83
85
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
84
86
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
85
87
  documentation_uri: https://rubydoc.info/gems/mcp