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 +4 -4
- data/README.md +237 -5
- data/lib/mcp/client/http.rb +133 -14
- data/lib/mcp/client/paginated_result.rb +13 -0
- data/lib/mcp/client.rb +195 -22
- data/lib/mcp/methods.rb +2 -4
- data/lib/mcp/server/pagination.rb +42 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +19 -6
- data/lib/mcp/server.rb +57 -17
- data/lib/mcp/server_context.rb +18 -0
- data/lib/mcp/server_session.rb +16 -0
- data/lib/mcp/version.rb +1 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd749db825bb7586ca38768025dbd6c4e6e9e9aa5805666a7d9ee4b5f54a59f3
|
|
4
|
+
data.tar.gz: a7dbc93581cfda1a22a6740f33047586902efcc30153a1ebda836a3623d267b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
219
|
+
"No valid JSON-RPC response found in SSE stream",
|
|
101
220
|
{ method: method, params: params },
|
|
102
|
-
error_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
|
|
47
|
-
#
|
|
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
|
-
|
|
106
|
+
# TODO: consider renaming to `list_all_tools`.
|
|
107
|
+
all_tools = []
|
|
108
|
+
seen = Set.new
|
|
109
|
+
cursor = nil
|
|
58
110
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
69
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
220
|
+
ListPromptsResult.new(
|
|
221
|
+
prompts: result["prompts"] || [],
|
|
222
|
+
next_cursor: result["nextCursor"],
|
|
223
|
+
meta: result["_meta"],
|
|
224
|
+
)
|
|
86
225
|
end
|
|
87
226
|
|
|
88
|
-
# Returns
|
|
89
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
289
|
+
stream.flush
|
|
278
290
|
end
|
|
279
291
|
|
|
280
292
|
def handle_post(request)
|
|
281
|
-
|
|
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::
|
|
380
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
data/lib/mcp/server_context.rb
CHANGED
|
@@ -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.
|
data/lib/mcp/server_session.rb
CHANGED
|
@@ -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
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.
|
|
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://
|
|
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.
|
|
82
|
-
homepage_uri: https://
|
|
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
|