Merge remote-tracking branch 'upstream/main' into make_server_context_optional_and_sorbet_friendly
This commit is contained in:
commit
5d0c5b7f0e
9 changed files with 2885 additions and 22 deletions
|
|
@ -13,6 +13,7 @@ require_relative "mcp/resource/contents"
|
|||
require_relative "mcp/resource/embedded"
|
||||
require_relative "mcp/resource_template"
|
||||
require_relative "mcp/server"
|
||||
require_relative "mcp/server/transports/streamable_http_transport"
|
||||
require_relative "mcp/server/transports/stdio_transport"
|
||||
require_relative "mcp/string_utils"
|
||||
require_relative "mcp/tool"
|
||||
|
|
|
|||
|
|
@ -4,12 +4,18 @@ module MCP
|
|||
class Configuration
|
||||
DEFAULT_PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
attr_writer :exception_reporter, :instrumentation_callback, :protocol_version
|
||||
attr_writer :exception_reporter, :instrumentation_callback, :protocol_version, :validate_tool_call_arguments
|
||||
|
||||
def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil)
|
||||
def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil,
|
||||
validate_tool_call_arguments: true)
|
||||
@exception_reporter = exception_reporter
|
||||
@instrumentation_callback = instrumentation_callback
|
||||
@protocol_version = protocol_version
|
||||
unless validate_tool_call_arguments.is_a?(TrueClass) || validate_tool_call_arguments.is_a?(FalseClass)
|
||||
raise ArgumentError, "validate_tool_call_arguments must be a boolean"
|
||||
end
|
||||
|
||||
@validate_tool_call_arguments = validate_tool_call_arguments
|
||||
end
|
||||
|
||||
def protocol_version
|
||||
|
|
@ -36,6 +42,12 @@ module MCP
|
|||
!@instrumentation_callback.nil?
|
||||
end
|
||||
|
||||
attr_reader :validate_tool_call_arguments
|
||||
|
||||
def validate_tool_call_arguments?
|
||||
!!@validate_tool_call_arguments
|
||||
end
|
||||
|
||||
def merge(other)
|
||||
return self if other.nil?
|
||||
|
||||
|
|
@ -54,11 +66,13 @@ module MCP
|
|||
else
|
||||
@protocol_version
|
||||
end
|
||||
validate_tool_call_arguments = other.validate_tool_call_arguments
|
||||
|
||||
Configuration.new(
|
||||
exception_reporter:,
|
||||
instrumentation_callback:,
|
||||
protocol_version:,
|
||||
validate_tool_call_arguments:,
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ module MCP
|
|||
|
||||
SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
|
||||
|
||||
# Notification methods
|
||||
NOTIFICATIONS_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"
|
||||
NOTIFICATIONS_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"
|
||||
NOTIFICATIONS_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"
|
||||
|
||||
class MissingRequiredCapabilityError < StandardError
|
||||
attr_reader :method
|
||||
attr_reader :capability
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ module MCP
|
|||
include Instrumentation
|
||||
|
||||
attr_writer :capabilities
|
||||
attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration
|
||||
attr_accessor :name, :version, :tools, :prompts, :resources, :server_context, :configuration, :transport
|
||||
|
||||
def initialize(
|
||||
name: "model_context_protocol",
|
||||
|
|
@ -35,7 +35,8 @@ module MCP
|
|||
resource_templates: [],
|
||||
server_context: nil,
|
||||
configuration: nil,
|
||||
capabilities: nil
|
||||
capabilities: nil,
|
||||
transport: nil
|
||||
)
|
||||
@name = name
|
||||
@version = version
|
||||
|
|
@ -68,6 +69,7 @@ module MCP
|
|||
Methods::COMPLETION_COMPLETE => ->(_) {},
|
||||
Methods::LOGGING_SET_LEVEL => ->(_) {},
|
||||
}
|
||||
@transport = transport
|
||||
end
|
||||
|
||||
def capabilities
|
||||
|
|
@ -96,6 +98,30 @@ module MCP
|
|||
@prompts[prompt.name_value] = prompt
|
||||
end
|
||||
|
||||
def notify_tools_list_changed
|
||||
return unless @transport
|
||||
|
||||
@transport.send_notification(Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED)
|
||||
rescue => e
|
||||
report_exception(e, { notification: "tools_list_changed" })
|
||||
end
|
||||
|
||||
def notify_prompts_list_changed
|
||||
return unless @transport
|
||||
|
||||
@transport.send_notification(Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED)
|
||||
rescue => e
|
||||
report_exception(e, { notification: "prompts_list_changed" })
|
||||
end
|
||||
|
||||
def notify_resources_list_changed
|
||||
return unless @transport
|
||||
|
||||
@transport.send_notification(Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED)
|
||||
rescue => e
|
||||
report_exception(e, { notification: "resources_list_changed" })
|
||||
end
|
||||
|
||||
def resources_list_handler(&block)
|
||||
@capabilities.support_resources
|
||||
@handlers[Methods::RESOURCES_LIST] = block
|
||||
|
|
@ -176,7 +202,6 @@ module MCP
|
|||
end
|
||||
|
||||
def init(request)
|
||||
add_instrumentation_data(method: Methods::INITIALIZE)
|
||||
{
|
||||
protocolVersion: configuration.protocol_version,
|
||||
capabilities: capabilities,
|
||||
|
|
@ -185,12 +210,10 @@ module MCP
|
|||
end
|
||||
|
||||
def list_tools(request)
|
||||
add_instrumentation_data(method: Methods::TOOLS_LIST)
|
||||
@tools.map { |_, tool| tool.to_h }
|
||||
end
|
||||
|
||||
def call_tool(request)
|
||||
add_instrumentation_data(method: Methods::TOOLS_CALL)
|
||||
tool_name = request[:name]
|
||||
tool = tools[tool_name]
|
||||
unless tool
|
||||
|
|
@ -210,6 +233,15 @@ module MCP
|
|||
)
|
||||
end
|
||||
|
||||
if configuration.validate_tool_call_arguments && tool.input_schema
|
||||
begin
|
||||
tool.input_schema.validate_arguments(arguments)
|
||||
rescue Tool::InputSchema::ValidationError => e
|
||||
add_instrumentation_data(error: :invalid_schema)
|
||||
raise RequestHandlerError.new(e.message, request, error_type: :invalid_schema)
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
call_tool_with_args(tool, arguments)
|
||||
rescue => e
|
||||
|
|
@ -218,12 +250,10 @@ module MCP
|
|||
end
|
||||
|
||||
def list_prompts(request)
|
||||
add_instrumentation_data(method: Methods::PROMPTS_LIST)
|
||||
@prompts.map { |_, prompt| prompt.to_h }
|
||||
end
|
||||
|
||||
def get_prompt(request)
|
||||
add_instrumentation_data(method: Methods::PROMPTS_GET)
|
||||
prompt_name = request[:name]
|
||||
prompt = @prompts[prompt_name]
|
||||
unless prompt
|
||||
|
|
@ -240,21 +270,16 @@ module MCP
|
|||
end
|
||||
|
||||
def list_resources(request)
|
||||
add_instrumentation_data(method: Methods::RESOURCES_LIST)
|
||||
|
||||
@resources.map(&:to_h)
|
||||
end
|
||||
|
||||
# Server implementation should set `resources_read_handler` to override no-op default
|
||||
def read_resource_no_content(request)
|
||||
add_instrumentation_data(method: Methods::RESOURCES_READ)
|
||||
add_instrumentation_data(resource_uri: request[:uri])
|
||||
[]
|
||||
end
|
||||
|
||||
def list_resource_templates(request)
|
||||
add_instrumentation_data(method: Methods::RESOURCES_TEMPLATES_LIST)
|
||||
|
||||
@resource_templates.map(&:to_h)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,20 @@ module MCP
|
|||
$stdout.puts(json_message)
|
||||
$stdout.flush
|
||||
end
|
||||
|
||||
def send_notification(method, params = nil)
|
||||
notification = {
|
||||
jsonrpc: "2.0",
|
||||
method: method,
|
||||
}
|
||||
notification[:params] = params if params
|
||||
|
||||
send_response(notification)
|
||||
true
|
||||
rescue => e
|
||||
MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
289
lib/mcp/server/transports/streamable_http_transport.rb
Normal file
289
lib/mcp/server/transports/streamable_http_transport.rb
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../../transport"
|
||||
require "json"
|
||||
require "securerandom"
|
||||
|
||||
module MCP
|
||||
class Server
|
||||
module Transports
|
||||
class StreamableHTTPTransport < Transport
|
||||
def initialize(server)
|
||||
super
|
||||
# { session_id => { stream: stream_object }
|
||||
@sessions = {}
|
||||
@mutex = Mutex.new
|
||||
end
|
||||
|
||||
def handle_request(request)
|
||||
case request.env["REQUEST_METHOD"]
|
||||
when "POST"
|
||||
handle_post(request)
|
||||
when "GET"
|
||||
handle_get(request)
|
||||
when "DELETE"
|
||||
handle_delete(request)
|
||||
else
|
||||
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
|
||||
end
|
||||
end
|
||||
|
||||
def close
|
||||
@mutex.synchronize do
|
||||
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
|
||||
end
|
||||
end
|
||||
|
||||
def send_notification(notification, session_id: nil)
|
||||
@mutex.synchronize do
|
||||
if session_id
|
||||
# Send to specific session
|
||||
session = @sessions[session_id]
|
||||
return false unless session && session[:stream]
|
||||
|
||||
begin
|
||||
send_to_stream(session[:stream], notification)
|
||||
true
|
||||
rescue IOError, Errno::EPIPE => e
|
||||
MCP.configuration.exception_reporter.call(
|
||||
e,
|
||||
{ session_id: session_id, error: "Failed to send notification" },
|
||||
)
|
||||
cleanup_session_unsafe(session_id)
|
||||
false
|
||||
end
|
||||
else
|
||||
# Broadcast to all connected SSE sessions
|
||||
sent_count = 0
|
||||
failed_sessions = []
|
||||
|
||||
@sessions.each do |sid, session|
|
||||
next unless session[:stream]
|
||||
|
||||
begin
|
||||
send_to_stream(session[:stream], notification)
|
||||
sent_count += 1
|
||||
rescue IOError, Errno::EPIPE => e
|
||||
MCP.configuration.exception_reporter.call(
|
||||
e,
|
||||
{ session_id: sid, error: "Failed to send notification" },
|
||||
)
|
||||
failed_sessions << sid
|
||||
end
|
||||
end
|
||||
|
||||
# Clean up failed sessions
|
||||
failed_sessions.each { |sid| cleanup_session_unsafe(sid) }
|
||||
|
||||
sent_count
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_to_stream(stream, data)
|
||||
message = data.is_a?(String) ? data : data.to_json
|
||||
stream.write("data: #{message}\n\n")
|
||||
stream.flush if stream.respond_to?(:flush)
|
||||
end
|
||||
|
||||
def send_ping_to_stream(stream)
|
||||
stream.write(": ping #{Time.now.iso8601}\n\n")
|
||||
stream.flush if stream.respond_to?(:flush)
|
||||
end
|
||||
|
||||
def handle_post(request)
|
||||
body_string = request.body.read
|
||||
session_id = extract_session_id(request)
|
||||
|
||||
body = parse_request_body(body_string)
|
||||
return body unless body.is_a?(Hash) # Error response
|
||||
|
||||
if body["method"] == "initialize"
|
||||
handle_initialization(body_string, body)
|
||||
else
|
||||
handle_regular_request(body_string, session_id)
|
||||
end
|
||||
rescue StandardError => e
|
||||
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
||||
[500, { "Content-Type" => "application/json" }, [{ error: "Internal server error" }.to_json]]
|
||||
end
|
||||
|
||||
def handle_get(request)
|
||||
session_id = extract_session_id(request)
|
||||
|
||||
return missing_session_id_response unless session_id
|
||||
return session_not_found_response unless session_exists?(session_id)
|
||||
|
||||
setup_sse_stream(session_id)
|
||||
end
|
||||
|
||||
def handle_delete(request)
|
||||
session_id = request.env["HTTP_MCP_SESSION_ID"]
|
||||
|
||||
return [
|
||||
400,
|
||||
{ "Content-Type" => "application/json" },
|
||||
[{ error: "Missing session ID" }.to_json],
|
||||
] unless session_id
|
||||
|
||||
cleanup_session(session_id)
|
||||
[200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
|
||||
end
|
||||
|
||||
def cleanup_session(session_id)
|
||||
@mutex.synchronize do
|
||||
cleanup_session_unsafe(session_id)
|
||||
end
|
||||
end
|
||||
|
||||
def cleanup_session_unsafe(session_id)
|
||||
session = @sessions[session_id]
|
||||
return unless session
|
||||
|
||||
begin
|
||||
session[:stream]&.close
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
@sessions.delete(session_id)
|
||||
end
|
||||
|
||||
def extract_session_id(request)
|
||||
request.env["HTTP_MCP_SESSION_ID"]
|
||||
end
|
||||
|
||||
def parse_request_body(body_string)
|
||||
JSON.parse(body_string)
|
||||
rescue JSON::ParserError, TypeError
|
||||
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
||||
end
|
||||
|
||||
def handle_initialization(body_string, body)
|
||||
session_id = SecureRandom.uuid
|
||||
|
||||
@mutex.synchronize do
|
||||
@sessions[session_id] = {
|
||||
stream: nil,
|
||||
}
|
||||
end
|
||||
|
||||
response = @server.handle_json(body_string)
|
||||
|
||||
headers = {
|
||||
"Content-Type" => "application/json",
|
||||
"Mcp-Session-Id" => session_id,
|
||||
}
|
||||
|
||||
[200, headers, [response]]
|
||||
end
|
||||
|
||||
def handle_regular_request(body_string, session_id)
|
||||
# If session ID is provided, but not in the sessions hash, return an error
|
||||
if session_id && !@sessions.key?(session_id)
|
||||
return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
|
||||
end
|
||||
|
||||
response = @server.handle_json(body_string)
|
||||
stream = get_session_stream(session_id) if session_id
|
||||
|
||||
if stream
|
||||
send_response_to_stream(stream, response, session_id)
|
||||
else
|
||||
[200, { "Content-Type" => "application/json" }, [response]]
|
||||
end
|
||||
end
|
||||
|
||||
def get_session_stream(session_id)
|
||||
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
|
||||
end
|
||||
|
||||
def send_response_to_stream(stream, response, session_id)
|
||||
message = JSON.parse(response)
|
||||
send_to_stream(stream, message)
|
||||
[200, { "Content-Type" => "application/json" }, [{ accepted: true }.to_json]]
|
||||
rescue IOError, Errno::EPIPE => e
|
||||
MCP.configuration.exception_reporter.call(
|
||||
e,
|
||||
{ session_id: session_id, error: "Stream closed during response" },
|
||||
)
|
||||
cleanup_session(session_id)
|
||||
[200, { "Content-Type" => "application/json" }, [response]]
|
||||
end
|
||||
|
||||
def session_exists?(session_id)
|
||||
@mutex.synchronize { @sessions.key?(session_id) }
|
||||
end
|
||||
|
||||
def missing_session_id_response
|
||||
[400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
|
||||
end
|
||||
|
||||
def session_not_found_response
|
||||
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
||||
end
|
||||
|
||||
def setup_sse_stream(session_id)
|
||||
body = create_sse_body(session_id)
|
||||
|
||||
headers = {
|
||||
"Content-Type" => "text/event-stream",
|
||||
"Cache-Control" => "no-cache",
|
||||
"Connection" => "keep-alive",
|
||||
}
|
||||
|
||||
[200, headers, body]
|
||||
end
|
||||
|
||||
def create_sse_body(session_id)
|
||||
proc do |stream|
|
||||
store_stream_for_session(session_id, stream)
|
||||
start_keepalive_thread(session_id)
|
||||
end
|
||||
end
|
||||
|
||||
def store_stream_for_session(session_id, stream)
|
||||
@mutex.synchronize do
|
||||
if @sessions[session_id]
|
||||
@sessions[session_id][:stream] = stream
|
||||
else
|
||||
stream.close
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def start_keepalive_thread(session_id)
|
||||
Thread.new do
|
||||
while session_active_with_stream?(session_id)
|
||||
sleep(30)
|
||||
send_keepalive_ping(session_id)
|
||||
end
|
||||
rescue StandardError => e
|
||||
MCP.configuration.exception_reporter.call(e, { session_id: session_id })
|
||||
ensure
|
||||
cleanup_session(session_id)
|
||||
end
|
||||
end
|
||||
|
||||
def session_active_with_stream?(session_id)
|
||||
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:stream] }
|
||||
end
|
||||
|
||||
def send_keepalive_ping(session_id)
|
||||
@mutex.synchronize do
|
||||
if @sessions[session_id] && @sessions[session_id][:stream]
|
||||
send_ping_to_stream(@sessions[session_id][:stream])
|
||||
end
|
||||
end
|
||||
rescue IOError, Errno::EPIPE => e
|
||||
MCP.configuration.exception_reporter.call(
|
||||
e,
|
||||
{ session_id: session_id, error: "Stream closed" },
|
||||
)
|
||||
raise # Re-raise to exit the keepalive loop
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,13 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "json-schema"
|
||||
|
||||
module MCP
|
||||
class Tool
|
||||
class InputSchema
|
||||
class ValidationError < StandardError; end
|
||||
|
||||
attr_reader :properties, :required
|
||||
|
||||
def initialize(properties: {}, required: [])
|
||||
@properties = properties
|
||||
@required = required.map(&:to_sym)
|
||||
validate_schema!
|
||||
end
|
||||
|
||||
def to_h
|
||||
|
|
@ -21,6 +26,42 @@ module MCP
|
|||
def missing_required_arguments(arguments)
|
||||
(required - arguments.keys.map(&:to_sym))
|
||||
end
|
||||
|
||||
def validate_arguments(arguments)
|
||||
errors = JSON::Validator.fully_validate(to_h, arguments)
|
||||
if errors.any?
|
||||
raise ValidationError, "Invalid arguments: #{errors.join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_schema!
|
||||
check_for_refs!
|
||||
schema = to_h
|
||||
schema_reader = JSON::Schema::Reader.new(
|
||||
accept_uri: false,
|
||||
accept_file: ->(path) { path.to_s.start_with?(Gem.loaded_specs["json-schema"].full_gem_path) },
|
||||
)
|
||||
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
|
||||
errors = JSON::Validator.fully_validate(metaschema, schema, schema_reader: schema_reader)
|
||||
if errors.any?
|
||||
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
def check_for_refs!(obj = properties)
|
||||
case obj
|
||||
when Hash
|
||||
if obj.key?("$ref") || obj.key?(:$ref)
|
||||
raise ArgumentError, "Invalid JSON Schema: $ref is not allowed in tool input schemas"
|
||||
end
|
||||
|
||||
obj.each_value { |value| check_for_refs!(value) }
|
||||
when Array
|
||||
obj.each { |item| check_for_refs!(item) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,32 +2,44 @@
|
|||
|
||||
module MCP
|
||||
class Transport
|
||||
# Initialize the transport with the server instance
|
||||
def initialize(server)
|
||||
@server = server
|
||||
end
|
||||
|
||||
# Send a response to the client
|
||||
def send_response(response)
|
||||
raise NotImplementedError, "Subclasses must implement send_response"
|
||||
end
|
||||
|
||||
# Open the transport connection
|
||||
def open
|
||||
raise NotImplementedError, "Subclasses must implement open"
|
||||
end
|
||||
|
||||
# Close the transport connection
|
||||
def close
|
||||
raise NotImplementedError, "Subclasses must implement close"
|
||||
end
|
||||
|
||||
private
|
||||
# Handle a JSON request
|
||||
# Returns a response that should be sent back to the client
|
||||
def handle_json_request(request)
|
||||
response = @server.handle_json(request)
|
||||
send_response(response) if response
|
||||
end
|
||||
|
||||
# Handle an incoming request
|
||||
# Returns a response that should be sent back to the client
|
||||
def handle_request(request)
|
||||
response = @server.handle(request)
|
||||
send_response(response) if response
|
||||
end
|
||||
|
||||
def handle_json_request(request)
|
||||
response = @server.handle_json(request)
|
||||
send_response(response) if response
|
||||
# Send a notification to the client
|
||||
# Returns true if the notification was sent successfully
|
||||
def send_notification(method, params = nil)
|
||||
raise NotImplementedError, "Subclasses must implement send_notification"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
15
lib/mcp/transports/stdio.rb
Normal file
15
lib/mcp/transports/stdio.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../server/transports/stdio_transport"
|
||||
|
||||
warn <<~MESSAGE, uplevel: 3
|
||||
Use `require "mcp/server/transports/stdio_transport"` instead of `require "mcp/transports/stdio"`.
|
||||
Also use `MCP::Server::Transports::StdioTransport` instead of `MCP::Transports::StdioTransport`.
|
||||
This API is deprecated and will be removed in a future release.
|
||||
MESSAGE
|
||||
|
||||
module MCP
|
||||
module Transports
|
||||
StdioTransport = Server::Transports::StdioTransport
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue