mcp 0.1.0 → 0.2.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/.cursor/rules/release-changelogs.mdc +11 -24
- data/.github/workflows/release.yml +25 -0
- data/.rubocop.yml +2 -3
- data/CHANGELOG.md +31 -0
- data/Gemfile +12 -5
- data/README.md +120 -25
- data/examples/README.md +197 -0
- data/examples/http_client.rb +184 -0
- data/examples/http_server.rb +168 -0
- data/examples/stdio_server.rb +2 -3
- data/examples/streamable_http_client.rb +205 -0
- data/examples/streamable_http_server.rb +172 -0
- data/lib/mcp/configuration.rb +16 -2
- data/lib/mcp/methods.rb +55 -33
- data/lib/mcp/prompt.rb +2 -2
- data/lib/mcp/resource.rb +1 -1
- data/lib/mcp/server/capabilities.rb +96 -0
- data/lib/mcp/server/transports/stdio_transport.rb +57 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +289 -0
- data/lib/mcp/server.rb +80 -45
- data/lib/mcp/tool/input_schema.rb +44 -1
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/transport.rb +16 -4
- data/lib/mcp/transports/stdio.rb +8 -28
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +16 -12
- data/mcp.gemspec +1 -2
- metadata +17 -24
@@ -0,0 +1,289 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../transport"
|
4
|
+
require "json"
|
5
|
+
require "securerandom"
|
6
|
+
|
7
|
+
module MCP
|
8
|
+
class Server
|
9
|
+
module Transports
|
10
|
+
class StreamableHTTPTransport < Transport
|
11
|
+
def initialize(server)
|
12
|
+
super
|
13
|
+
# { session_id => { stream: stream_object }
|
14
|
+
@sessions = {}
|
15
|
+
@mutex = Mutex.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def handle_request(request)
|
19
|
+
case request.env["REQUEST_METHOD"]
|
20
|
+
when "POST"
|
21
|
+
handle_post(request)
|
22
|
+
when "GET"
|
23
|
+
handle_get(request)
|
24
|
+
when "DELETE"
|
25
|
+
handle_delete(request)
|
26
|
+
else
|
27
|
+
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def close
|
32
|
+
@mutex.synchronize do
|
33
|
+
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def send_notification(notification, session_id: nil)
|
38
|
+
@mutex.synchronize do
|
39
|
+
if session_id
|
40
|
+
# Send to specific session
|
41
|
+
session = @sessions[session_id]
|
42
|
+
return false unless session && session[:stream]
|
43
|
+
|
44
|
+
begin
|
45
|
+
send_to_stream(session[:stream], notification)
|
46
|
+
true
|
47
|
+
rescue IOError, Errno::EPIPE => e
|
48
|
+
MCP.configuration.exception_reporter.call(
|
49
|
+
e,
|
50
|
+
{ session_id: session_id, error: "Failed to send notification" },
|
51
|
+
)
|
52
|
+
cleanup_session_unsafe(session_id)
|
53
|
+
false
|
54
|
+
end
|
55
|
+
else
|
56
|
+
# Broadcast to all connected SSE sessions
|
57
|
+
sent_count = 0
|
58
|
+
failed_sessions = []
|
59
|
+
|
60
|
+
@sessions.each do |sid, session|
|
61
|
+
next unless session[:stream]
|
62
|
+
|
63
|
+
begin
|
64
|
+
send_to_stream(session[:stream], notification)
|
65
|
+
sent_count += 1
|
66
|
+
rescue IOError, Errno::EPIPE => e
|
67
|
+
MCP.configuration.exception_reporter.call(
|
68
|
+
e,
|
69
|
+
{ session_id: sid, error: "Failed to send notification" },
|
70
|
+
)
|
71
|
+
failed_sessions << sid
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Clean up failed sessions
|
76
|
+
failed_sessions.each { |sid| cleanup_session_unsafe(sid) }
|
77
|
+
|
78
|
+
sent_count
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def send_to_stream(stream, data)
|
86
|
+
message = data.is_a?(String) ? data : data.to_json
|
87
|
+
stream.write("data: #{message}\n\n")
|
88
|
+
stream.flush if stream.respond_to?(:flush)
|
89
|
+
end
|
90
|
+
|
91
|
+
def send_ping_to_stream(stream)
|
92
|
+
stream.write(": ping #{Time.now.iso8601}\n\n")
|
93
|
+
stream.flush if stream.respond_to?(:flush)
|
94
|
+
end
|
95
|
+
|
96
|
+
def handle_post(request)
|
97
|
+
body_string = request.body.read
|
98
|
+
session_id = extract_session_id(request)
|
99
|
+
|
100
|
+
body = parse_request_body(body_string)
|
101
|
+
return body unless body.is_a?(Hash) # Error response
|
102
|
+
|
103
|
+
if body["method"] == "initialize"
|
104
|
+
handle_initialization(body_string, body)
|
105
|
+
else
|
106
|
+
handle_regular_request(body_string, session_id)
|
107
|
+
end
|
108
|
+
rescue StandardError => e
|
109
|
+
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
110
|
+
[500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
|
111
|
+
end
|
112
|
+
|
113
|
+
def handle_get(request)
|
114
|
+
session_id = extract_session_id(request)
|
115
|
+
|
116
|
+
return missing_session_id_response unless session_id
|
117
|
+
return session_not_found_response unless session_exists?(session_id)
|
118
|
+
|
119
|
+
setup_sse_stream(session_id)
|
120
|
+
end
|
121
|
+
|
122
|
+
def handle_delete(request)
|
123
|
+
session_id = request.env["HTTP_MCP_SESSION_ID"]
|
124
|
+
|
125
|
+
return [
|
126
|
+
400,
|
127
|
+
{ "Content-Type" => "application/json" },
|
128
|
+
[{ error: "Missing session ID" }.to_json],
|
129
|
+
] unless session_id
|
130
|
+
|
131
|
+
cleanup_session(session_id)
|
132
|
+
[200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
|
133
|
+
end
|
134
|
+
|
135
|
+
def cleanup_session(session_id)
|
136
|
+
@mutex.synchronize do
|
137
|
+
cleanup_session_unsafe(session_id)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def cleanup_session_unsafe(session_id)
|
142
|
+
session = @sessions[session_id]
|
143
|
+
return unless session
|
144
|
+
|
145
|
+
begin
|
146
|
+
session[:stream]&.close
|
147
|
+
rescue
|
148
|
+
nil
|
149
|
+
end
|
150
|
+
@sessions.delete(session_id)
|
151
|
+
end
|
152
|
+
|
153
|
+
def extract_session_id(request)
|
154
|
+
request.env["HTTP_MCP_SESSION_ID"]
|
155
|
+
end
|
156
|
+
|
157
|
+
def parse_request_body(body_string)
|
158
|
+
JSON.parse(body_string)
|
159
|
+
rescue JSON::ParserError, TypeError
|
160
|
+
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
161
|
+
end
|
162
|
+
|
163
|
+
def handle_initialization(body_string, body)
|
164
|
+
session_id = SecureRandom.uuid
|
165
|
+
|
166
|
+
@mutex.synchronize do
|
167
|
+
@sessions[session_id] = {
|
168
|
+
stream: nil,
|
169
|
+
}
|
170
|
+
end
|
171
|
+
|
172
|
+
response = @server.handle_json(body_string)
|
173
|
+
|
174
|
+
headers = {
|
175
|
+
"Content-Type" => "application/json",
|
176
|
+
"Mcp-Session-Id" => session_id,
|
177
|
+
}
|
178
|
+
|
179
|
+
[200, headers, [response]]
|
180
|
+
end
|
181
|
+
|
182
|
+
def handle_regular_request(body_string, session_id)
|
183
|
+
# If session ID is provided, but not in the sessions hash, return an error
|
184
|
+
if session_id && !@sessions.key?(session_id)
|
185
|
+
return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
|
186
|
+
end
|
187
|
+
|
188
|
+
response = @server.handle_json(body_string)
|
189
|
+
stream = get_session_stream(session_id) if session_id
|
190
|
+
|
191
|
+
if stream
|
192
|
+
send_response_to_stream(stream, response, session_id)
|
193
|
+
else
|
194
|
+
[200, { "Content-Type" => "application/json" }, [response]]
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def get_session_stream(session_id)
|
199
|
+
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
|
200
|
+
end
|
201
|
+
|
202
|
+
def send_response_to_stream(stream, response, session_id)
|
203
|
+
message = JSON.parse(response)
|
204
|
+
send_to_stream(stream, message)
|
205
|
+
[200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]]
|
206
|
+
rescue IOError, Errno::EPIPE => e
|
207
|
+
MCP.configuration.exception_reporter.call(
|
208
|
+
e,
|
209
|
+
{ session_id: session_id, error: "Stream closed during response" },
|
210
|
+
)
|
211
|
+
cleanup_session(session_id)
|
212
|
+
[200, { "Content-Type" => "application/json" }, [response]]
|
213
|
+
end
|
214
|
+
|
215
|
+
def session_exists?(session_id)
|
216
|
+
@mutex.synchronize { @sessions.key?(session_id) }
|
217
|
+
end
|
218
|
+
|
219
|
+
def missing_session_id_response
|
220
|
+
[400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
|
221
|
+
end
|
222
|
+
|
223
|
+
def session_not_found_response
|
224
|
+
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
225
|
+
end
|
226
|
+
|
227
|
+
def setup_sse_stream(session_id)
|
228
|
+
body = create_sse_body(session_id)
|
229
|
+
|
230
|
+
headers = {
|
231
|
+
"Content-Type" => "text/event-stream",
|
232
|
+
"Cache-Control" => "no-cache",
|
233
|
+
"Connection" => "keep-alive",
|
234
|
+
}
|
235
|
+
|
236
|
+
[200, headers, body]
|
237
|
+
end
|
238
|
+
|
239
|
+
def create_sse_body(session_id)
|
240
|
+
proc do |stream|
|
241
|
+
store_stream_for_session(session_id, stream)
|
242
|
+
start_keepalive_thread(session_id)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def store_stream_for_session(session_id, stream)
|
247
|
+
@mutex.synchronize do
|
248
|
+
if @sessions[session_id]
|
249
|
+
@sessions[session_id][:stream] = stream
|
250
|
+
else
|
251
|
+
stream.close
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
def start_keepalive_thread(session_id)
|
257
|
+
Thread.new do
|
258
|
+
while session_active_with_stream?(session_id)
|
259
|
+
sleep(30)
|
260
|
+
send_keepalive_ping(session_id)
|
261
|
+
end
|
262
|
+
rescue StandardError => e
|
263
|
+
MCP.configuration.exception_reporter.call(e, { session_id: session_id })
|
264
|
+
ensure
|
265
|
+
cleanup_session(session_id)
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
def session_active_with_stream?(session_id)
|
270
|
+
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
|
271
|
+
end
|
272
|
+
|
273
|
+
def send_keepalive_ping(session_id)
|
274
|
+
@mutex.synchronize do
|
275
|
+
if @sessions[session_id] && @sessions[session_id][:stream]
|
276
|
+
send_ping_to_stream(@sessions[session_id][:stream])
|
277
|
+
end
|
278
|
+
end
|
279
|
+
rescue IOError, Errno::EPIPE => e
|
280
|
+
MCP.configuration.exception_reporter.call(
|
281
|
+
e,
|
282
|
+
{ session_id: session_id, error: "Stream closed" },
|
283
|
+
)
|
284
|
+
raise # Re-raise to exit the keepalive loop
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
data/lib/mcp/server.rb
CHANGED
@@ -20,10 +20,18 @@ module MCP
|
|
20
20
|
end
|
21
21
|
end
|
22
22
|
|
23
|
+
class MethodAlreadyDefinedError < StandardError
|
24
|
+
attr_reader :method_name
|
25
|
+
|
26
|
+
def initialize(method_name)
|
27
|
+
super("Method #{method_name} already defined")
|
28
|
+
@method_name = method_name
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
23
32
|
include Instrumentation
|
24
33
|
|
25
|
-
|
26
|
-
attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration
|
34
|
+
attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
|
27
35
|
|
28
36
|
def initialize(
|
29
37
|
name: "model_context_protocol",
|
@@ -34,7 +42,8 @@ module MCP
|
|
34
42
|
resource_templates: [],
|
35
43
|
server_context: nil,
|
36
44
|
configuration: nil,
|
37
|
-
capabilities: nil
|
45
|
+
capabilities: nil,
|
46
|
+
transport: nil
|
38
47
|
)
|
39
48
|
@name = name
|
40
49
|
@version = version
|
@@ -45,6 +54,7 @@ module MCP
|
|
45
54
|
@resource_index = index_resources_by_uri(resources)
|
46
55
|
@server_context = server_context
|
47
56
|
@configuration = MCP.configuration.merge(configuration)
|
57
|
+
@capabilities = capabilities || default_capabilities
|
48
58
|
|
49
59
|
@handlers = {
|
50
60
|
Methods::RESOURCES_LIST => method(:list_resources),
|
@@ -63,10 +73,7 @@ module MCP
|
|
63
73
|
Methods::COMPLETION_COMPLETE => ->(_) {},
|
64
74
|
Methods::LOGGING_SET_LEVEL => ->(_) {},
|
65
75
|
}
|
66
|
-
|
67
|
-
|
68
|
-
def capabilities
|
69
|
-
@capabilities ||= determine_capabilities
|
76
|
+
@transport = transport
|
70
77
|
end
|
71
78
|
|
72
79
|
def handle(request)
|
@@ -91,6 +98,38 @@ module MCP
|
|
91
98
|
@prompts[prompt.name_value] = prompt
|
92
99
|
end
|
93
100
|
|
101
|
+
def define_custom_method(method_name:, &block)
|
102
|
+
if @handlers.key?(method_name)
|
103
|
+
raise MethodAlreadyDefinedError, method_name
|
104
|
+
end
|
105
|
+
|
106
|
+
@handlers[method_name] = block
|
107
|
+
end
|
108
|
+
|
109
|
+
def notify_tools_list_changed
|
110
|
+
return unless @transport
|
111
|
+
|
112
|
+
@transport.send_notification(Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED)
|
113
|
+
rescue => e
|
114
|
+
report_exception(e, { notification: "tools_list_changed" })
|
115
|
+
end
|
116
|
+
|
117
|
+
def notify_prompts_list_changed
|
118
|
+
return unless @transport
|
119
|
+
|
120
|
+
@transport.send_notification(Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED)
|
121
|
+
rescue => e
|
122
|
+
report_exception(e, { notification: "prompts_list_changed" })
|
123
|
+
end
|
124
|
+
|
125
|
+
def notify_resources_list_changed
|
126
|
+
return unless @transport
|
127
|
+
|
128
|
+
@transport.send_notification(Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED)
|
129
|
+
rescue => e
|
130
|
+
report_exception(e, { notification: "resources_list_changed" })
|
131
|
+
end
|
132
|
+
|
94
133
|
def resources_list_handler(&block)
|
95
134
|
@handlers[Methods::RESOURCES_LIST] = block
|
96
135
|
end
|
@@ -159,16 +198,12 @@ module MCP
|
|
159
198
|
}
|
160
199
|
end
|
161
200
|
|
162
|
-
def
|
163
|
-
defines_prompts = @prompts.any? || @handlers[Methods::PROMPTS_LIST] != method(:list_prompts)
|
164
|
-
defines_tools = @tools.any? || @handlers[Methods::TOOLS_LIST] != method(:list_tools)
|
165
|
-
defines_resources = @resources.any? || @handlers[Methods::RESOURCES_LIST] != method(:list_resources)
|
166
|
-
defines_resource_templates = @resource_templates.any? || @handlers[Methods::RESOURCES_TEMPLATES_LIST] != method(:list_resource_templates)
|
201
|
+
def default_capabilities
|
167
202
|
{
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
}
|
203
|
+
tools: { listChanged: true },
|
204
|
+
prompts: { listChanged: true },
|
205
|
+
resources: { listChanged: true },
|
206
|
+
}
|
172
207
|
end
|
173
208
|
|
174
209
|
def server_info
|
@@ -179,7 +214,6 @@ module MCP
|
|
179
214
|
end
|
180
215
|
|
181
216
|
def init(request)
|
182
|
-
add_instrumentation_data(method: Methods::INITIALIZE)
|
183
217
|
{
|
184
218
|
protocolVersion: configuration.protocol_version,
|
185
219
|
capabilities: capabilities,
|
@@ -188,12 +222,10 @@ module MCP
|
|
188
222
|
end
|
189
223
|
|
190
224
|
def list_tools(request)
|
191
|
-
add_instrumentation_data(method: Methods::TOOLS_LIST)
|
192
225
|
@tools.map { |_, tool| tool.to_h }
|
193
226
|
end
|
194
227
|
|
195
228
|
def call_tool(request)
|
196
|
-
add_instrumentation_data(method: Methods::TOOLS_CALL)
|
197
229
|
tool_name = request[:name]
|
198
230
|
tool = tools[tool_name]
|
199
231
|
unless tool
|
@@ -213,26 +245,27 @@ module MCP
|
|
213
245
|
)
|
214
246
|
end
|
215
247
|
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
tool.call(**arguments.transform_keys(&:to_sym)).to_h
|
248
|
+
if configuration.validate_tool_call_arguments && tool.input_schema
|
249
|
+
begin
|
250
|
+
tool.input_schema.validate_arguments(arguments)
|
251
|
+
rescue Tool::InputSchema::ValidationError => e
|
252
|
+
add_instrumentation_data(error: :invalid_schema)
|
253
|
+
raise RequestHandlerError.new(e.message, request, error_type: :invalid_schema)
|
223
254
|
end
|
255
|
+
end
|
256
|
+
|
257
|
+
begin
|
258
|
+
call_tool_with_args(tool, arguments)
|
224
259
|
rescue => e
|
225
260
|
raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e)
|
226
261
|
end
|
227
262
|
end
|
228
263
|
|
229
264
|
def list_prompts(request)
|
230
|
-
add_instrumentation_data(method: Methods::PROMPTS_LIST)
|
231
265
|
@prompts.map { |_, prompt| prompt.to_h }
|
232
266
|
end
|
233
267
|
|
234
268
|
def get_prompt(request)
|
235
|
-
add_instrumentation_data(method: Methods::PROMPTS_GET)
|
236
269
|
prompt_name = request[:name]
|
237
270
|
prompt = @prompts[prompt_name]
|
238
271
|
unless prompt
|
@@ -245,25 +278,20 @@ module MCP
|
|
245
278
|
prompt_args = request[:arguments]
|
246
279
|
prompt.validate_arguments!(prompt_args)
|
247
280
|
|
248
|
-
prompt
|
281
|
+
call_prompt_template_with_args(prompt, prompt_args)
|
249
282
|
end
|
250
283
|
|
251
284
|
def list_resources(request)
|
252
|
-
add_instrumentation_data(method: Methods::RESOURCES_LIST)
|
253
|
-
|
254
285
|
@resources.map(&:to_h)
|
255
286
|
end
|
256
287
|
|
257
288
|
# Server implementation should set `resources_read_handler` to override no-op default
|
258
289
|
def read_resource_no_content(request)
|
259
|
-
add_instrumentation_data(method: Methods::RESOURCES_READ)
|
260
290
|
add_instrumentation_data(resource_uri: request[:uri])
|
261
291
|
[]
|
262
292
|
end
|
263
293
|
|
264
294
|
def list_resource_templates(request)
|
265
|
-
add_instrumentation_data(method: Methods::RESOURCES_TEMPLATES_LIST)
|
266
|
-
|
267
295
|
@resource_templates.map(&:to_h)
|
268
296
|
end
|
269
297
|
|
@@ -277,22 +305,29 @@ module MCP
|
|
277
305
|
end
|
278
306
|
end
|
279
307
|
|
280
|
-
def
|
281
|
-
|
282
|
-
|
308
|
+
def accepts_server_context?(method_object)
|
309
|
+
parameters = method_object.parameters
|
310
|
+
accepts_server_context = parameters.any? { |_type, name| name == :server_context }
|
311
|
+
has_kwargs = parameters.any? { |type, _| type == :keyrest }
|
312
|
+
|
313
|
+
accepts_server_context || has_kwargs
|
283
314
|
end
|
284
315
|
|
285
|
-
def
|
286
|
-
|
316
|
+
def call_tool_with_args(tool, arguments)
|
317
|
+
args = arguments.transform_keys(&:to_sym)
|
287
318
|
|
288
|
-
if
|
289
|
-
|
319
|
+
if accepts_server_context?(tool.method(:call))
|
320
|
+
tool.call(**args, server_context: server_context).to_h
|
321
|
+
else
|
322
|
+
tool.call(**args).to_h
|
323
|
+
end
|
324
|
+
end
|
290
325
|
|
291
|
-
|
292
|
-
|
293
|
-
|
326
|
+
def call_prompt_template_with_args(prompt, args)
|
327
|
+
if accepts_server_context?(prompt.method(:template))
|
328
|
+
prompt.template(args, server_context: server_context).to_h
|
294
329
|
else
|
295
|
-
|
330
|
+
prompt.template(args).to_h
|
296
331
|
end
|
297
332
|
end
|
298
333
|
end
|
@@ -1,17 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "json-schema"
|
4
|
+
|
3
5
|
module MCP
|
4
6
|
class Tool
|
5
7
|
class InputSchema
|
8
|
+
class ValidationError < StandardError; end
|
9
|
+
|
6
10
|
attr_reader :properties, :required
|
7
11
|
|
8
12
|
def initialize(properties: {}, required: [])
|
9
13
|
@properties = properties
|
10
14
|
@required = required.map(&:to_sym)
|
15
|
+
validate_schema!
|
11
16
|
end
|
12
17
|
|
13
18
|
def to_h
|
14
|
-
{ type: "object", properties
|
19
|
+
{ type: "object", properties: }.tap do |hsh|
|
20
|
+
hsh[:required] = required if required.any?
|
21
|
+
end
|
15
22
|
end
|
16
23
|
|
17
24
|
def missing_required_arguments?(arguments)
|
@@ -21,6 +28,42 @@ module MCP
|
|
21
28
|
def missing_required_arguments(arguments)
|
22
29
|
(required - arguments.keys.map(&:to_sym))
|
23
30
|
end
|
31
|
+
|
32
|
+
def validate_arguments(arguments)
|
33
|
+
errors = JSON::Validator.fully_validate(to_h, arguments)
|
34
|
+
if errors.any?
|
35
|
+
raise ValidationError, "Invalid arguments: #{errors.join(", ")}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def validate_schema!
|
42
|
+
check_for_refs!
|
43
|
+
schema = to_h
|
44
|
+
schema_reader = JSON::Schema::Reader.new(
|
45
|
+
accept_uri: false,
|
46
|
+
accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) },
|
47
|
+
)
|
48
|
+
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
|
49
|
+
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
|
50
|
+
if errors.any?
|
51
|
+
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def check_for_refs!(obj = properties)
|
56
|
+
case obj
|
57
|
+
when Hash
|
58
|
+
if obj.key?("$ref") || obj.key?(:$ref)
|
59
|
+
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool input schemas"
|
60
|
+
end
|
61
|
+
|
62
|
+
obj.each_value { |value| check_for_refs!(value) }
|
63
|
+
when Array
|
64
|
+
obj.each { |item| check_for_refs!(item) }
|
65
|
+
end
|
66
|
+
end
|
24
67
|
end
|
25
68
|
end
|
26
69
|
end
|
data/lib/mcp/tool.rb
CHANGED
data/lib/mcp/transport.rb
CHANGED
@@ -2,32 +2,44 @@
|
|
2
2
|
|
3
3
|
module MCP
|
4
4
|
class Transport
|
5
|
+
# Initialize the transport with the server instance
|
5
6
|
def initialize(server)
|
6
7
|
@server = server
|
7
8
|
end
|
8
9
|
|
10
|
+
# Send a response to the client
|
9
11
|
def send_response(response)
|
10
12
|
raise NotImplementedError, "Subclasses must implement send_response"
|
11
13
|
end
|
12
14
|
|
15
|
+
# Open the transport connection
|
13
16
|
def open
|
14
17
|
raise NotImplementedError, "Subclasses must implement open"
|
15
18
|
end
|
16
19
|
|
20
|
+
# Close the transport connection
|
17
21
|
def close
|
18
22
|
raise NotImplementedError, "Subclasses must implement close"
|
19
23
|
end
|
20
24
|
|
21
|
-
|
25
|
+
# Handle a JSON request
|
26
|
+
# Returns a response that should be sent back to the client
|
27
|
+
def handle_json_request(request)
|
28
|
+
response = @server.handle_json(request)
|
29
|
+
send_response(response) if response
|
30
|
+
end
|
22
31
|
|
32
|
+
# Handle an incoming request
|
33
|
+
# Returns a response that should be sent back to the client
|
23
34
|
def handle_request(request)
|
24
35
|
response = @server.handle(request)
|
25
36
|
send_response(response) if response
|
26
37
|
end
|
27
38
|
|
28
|
-
|
29
|
-
|
30
|
-
|
39
|
+
# Send a notification to the client
|
40
|
+
# Returns true if the notification was sent successfully
|
41
|
+
def send_notification(method, params = nil)
|
42
|
+
raise NotImplementedError, "Subclasses must implement send_notification"
|
31
43
|
end
|
32
44
|
end
|
33
45
|
end
|
data/lib/mcp/transports/stdio.rb
CHANGED
@@ -1,35 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "../
|
4
|
-
|
3
|
+
require_relative "../server/transports/stdio_transport"
|
4
|
+
|
5
|
+
warn <<~MESSAGE, uplevel: 3
|
6
|
+
Use `require "mcp/server/transports/stdio_transport"` instead of `require "mcp/transports/stdio"`.
|
7
|
+
Also use `MCP::Server::Transports::StdioTransport` instead of `MCP::Transports::StdioTransport`.
|
8
|
+
This API is deprecated and will be removed in a future release.
|
9
|
+
MESSAGE
|
5
10
|
|
6
11
|
module MCP
|
7
12
|
module Transports
|
8
|
-
|
9
|
-
def initialize(server)
|
10
|
-
@server = server
|
11
|
-
@open = false
|
12
|
-
$stdin.set_encoding("UTF-8")
|
13
|
-
$stdout.set_encoding("UTF-8")
|
14
|
-
super
|
15
|
-
end
|
16
|
-
|
17
|
-
def open
|
18
|
-
@open = true
|
19
|
-
while @open && (line = $stdin.gets)
|
20
|
-
handle_json_request(line.strip)
|
21
|
-
end
|
22
|
-
end
|
23
|
-
|
24
|
-
def close
|
25
|
-
@open = false
|
26
|
-
end
|
27
|
-
|
28
|
-
def send_response(message)
|
29
|
-
json_message = message.is_a?(String) ? message : JSON.generate(message)
|
30
|
-
$stdout.puts(json_message)
|
31
|
-
$stdout.flush
|
32
|
-
end
|
33
|
-
end
|
13
|
+
StdioTransport = Server::Transports::StdioTransport
|
34
14
|
end
|
35
15
|
end
|
data/lib/mcp/version.rb
CHANGED