Internal Release 0.7.0

- add support for resource templates
- improve capability negotiation on handlers
- clean up readme
- reduce default protocol version to "2024-11-05"
- address spec compliance of resources
- add debug gem to development
- alias ModelContextProtocol to MCP
- move prompt subclasses to subdir
- improve tool and prompt definition
- improve passing of server context to tools
- improve tool input schema validation
This commit is contained in:
Tyler Rowsell 2025-05-08 10:11:20 -04:00 • committed by Topher Bullock
commit baeb99ffbc
15 changed files with 842 additions and 253 deletions

View file

@ -4,9 +4,18 @@
require "model_context_protocol/server"
require "model_context_protocol/string_utils"
require "model_context_protocol/tool"
require "model_context_protocol/tool/input_schema"
require "model_context_protocol/tool/annotations"
require "model_context_protocol/tool/response"
require "model_context_protocol/content"
require "model_context_protocol/resource"
require "model_context_protocol/resource/contents"
require "model_context_protocol/resource/embedded"
require "model_context_protocol/resource_template"
require "model_context_protocol/prompt"
require "model_context_protocol/prompt/argument"
require "model_context_protocol/prompt/message"
require "model_context_protocol/prompt/result"
require "model_context_protocol/version"
require "model_context_protocol/configuration"
require "model_context_protocol/methods"
@ -31,3 +40,5 @@ module ModelContextProtocol
end
end
end
MCP = ModelContextProtocol

View file

@ -2,7 +2,7 @@
module ModelContextProtocol
class Configuration
DEFAULT_PROTOCOL_VERSION = "2025-03-26"
DEFAULT_PROTOCOL_VERSION = "2024-11-05"
attr_writer :exception_reporter, :instrumentation_callback, :protocol_version

View file

@ -2,13 +2,85 @@
module ModelContextProtocol
module Methods
RESOURCES_LIST = "resources/list"
RESOURCES_READ = "resources/read"
TOOLS_LIST = "tools/list"
TOOLS_CALL = "tools/call"
PROMPTS_LIST = "prompts/list"
PROMPTS_GET = "prompts/get"
INITIALIZE = "initialize"
PING = "ping"
LOGGING_SET_LEVEL = "logging/setLevel"
PROMPTS_GET = "prompts/get"
PROMPTS_LIST = "prompts/list"
COMPLETION_COMPLETE = "completion/complete"
RESOURCES_LIST = "resources/list"
RESOURCES_READ = "resources/read"
RESOURCES_TEMPLATES_LIST = "resources/templates/list"
RESOURCES_SUBSCRIBE = "resources/subscribe"
RESOURCES_UNSUBSCRIBE = "resources/unsubscribe"
TOOLS_CALL = "tools/call"
TOOLS_LIST = "tools/list"
SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
class MissingRequiredCapabilityError < StandardError
attr_reader :method
attr_reader :capability
def initialize(method, capability)
super("Server does not support #{capability} (required for #{method})")
@method = method
@capability = capability
end
end
extend self
ALL_METHODS = [
SAMPLING_CREATE_MESSAGE,
INITIALIZE,
LOGGING_SET_LEVEL,
PING,
PROMPTS_GET,
PROMPTS_LIST,
COMPLETION_COMPLETE,
RESOURCES_LIST,
RESOURCES_READ,
RESOURCES_TEMPLATES_LIST,
RESOURCES_SUBSCRIBE,
RESOURCES_UNSUBSCRIBE,
TOOLS_CALL,
TOOLS_LIST,
]
def ensure_capability!(method, capabilities)
case method
when PROMPTS_GET, PROMPTS_LIST, COMPLETION_COMPLETE
unless capabilities[:prompts]
raise MissingRequiredCapabilityError.new(method, :prompts)
end
when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ, RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE
unless capabilities[:resources]
raise MissingRequiredCapabilityError.new(method, :resources)
end
if method == RESOURCES_SUBSCRIBE && !capabilities[:resources][:subscribe]
raise MissingRequiredCapabilityError.new(method, :resources_subscribe)
end
when TOOLS_CALL, TOOLS_LIST
unless capabilities[:tools]
raise MissingRequiredCapabilityError.new(method, :tools)
end
when SAMPLING_CREATE_MESSAGE
unless capabilities[:sampling]
raise MissingRequiredCapabilityError.new(method, :sampling)
end
when LOGGING_SET_LEVEL
# Logging is unsupported by the Server
unless capabilities[:logging]
raise MissingRequiredCapabilityError.new(method, :logging)
end
when INITIALIZE, PING
# No specific capability required for initialize or ping
end
end
end
end

View file

@ -3,47 +3,6 @@
module ModelContextProtocol
class Prompt
class Argument
attr_reader :name, :description, :required, :arguments
def initialize(name:, description: nil, required: false)
@name = name
@description = description
@required = required
@arguments = arguments
end
def to_h
{ name:, description:, required: }.compact
end
end
class Message
attr_reader :role, :content
def initialize(role:, content:)
@role = role
@content = content
end
def to_h
{ role:, content: content.to_h }.compact
end
end
class Result
attr_reader :description, :messages
def initialize(description: nil, messages: [])
@description = description
@messages = messages
end
def to_h
{ description:, messages: messages.map(&:to_h) }.compact
end
end
class << self
NOT_SET = Object.new

View file

@ -0,0 +1,21 @@
# typed: strict
# frozen_string_literal: true
module ModelContextProtocol
class Prompt
class Argument
attr_reader :name, :description, :required, :arguments
def initialize(name:, description: nil, required: false)
@name = name
@description = description
@required = required
@arguments = arguments
end
def to_h
{ name:, description:, required: }.compact
end
end
end
end

View file

@ -0,0 +1,19 @@
# typed: strict
# frozen_string_literal: true
module ModelContextProtocol
class Prompt
class Message
attr_reader :role, :content
def initialize(role:, content:)
@role = role
@content = content
end
def to_h
{ role:, content: content.to_h }.compact
end
end
end
end

View file

@ -0,0 +1,19 @@
# typed: strict
# frozen_string_literal: true
module ModelContextProtocol
class Prompt
class Result
attr_reader :description, :messages
def initialize(description: nil, messages: [])
@description = description
@messages = messages
end
def to_h
{ description:, messages: messages.map(&:to_h) }.compact
end
end
end
end

View file

@ -3,14 +3,13 @@
module ModelContextProtocol
class Resource
attr_reader :uri, :name, :description, :mime_type, :contents
attr_reader :uri, :name, :description, :mime_type
def initialize(uri:, name:, description:, mime_type:, contents:)
def initialize(uri:, name:, description:, mime_type:)
@uri = uri
@name = name
@description = description
@mime_type = mime_type
@contents = contents
end
def to_h
@ -19,7 +18,6 @@ module ModelContextProtocol
name: @name,
description: @description,
mimeType: @mime_type,
contents: @contents.map(&:to_h),
}
end
end

View file

@ -0,0 +1,24 @@
# typed: strict
# frozen_string_literal: true
module ModelContextProtocol
class ResourceTemplate
attr_reader :uri_template, :name, :description, :mime_type
def initialize(uri_template:, name:, description: nil, mime_type: nil)
@uri_template = uri_template
@name = name
@description = description
@mime_type = mime_type
end
def to_h
{
uriTemplate: @uri_template,
name: @name,
description: @description,
mimeType: @mime_type,
}.compact
end
end
end

View file

@ -20,26 +20,43 @@ module ModelContextProtocol
include Instrumentation
attr_accessor :name, :tools, :prompts, :resources, :server_context, :configuration
attr_accessor :name, :tools, :prompts, :resources, :server_context, :configuration, :capabilities
def initialize(name: "model_context_protocol", tools: [], prompts: [], resources: [], server_context: nil,
configuration: nil)
def initialize(
name: "model_context_protocol",
tools: [],
prompts: [],
resources: [],
resource_templates: [],
server_context: nil,
configuration: nil,
capabilities: { prompts: {}, resources: {}, tools: {} }
)
@name = name
@tools = tools.to_h { |t| [t.name_value, t] }
@prompts = prompts.to_h { |p| [p.name_value, p] }
@resources = resources
@resource_templates = resource_templates
@resource_index = index_resources_by_uri(resources)
@server_context = server_context
@configuration = ModelContextProtocol.configuration.merge(configuration)
@capabilities = capabilities
@handlers = {
Methods::RESOURCES_LIST => method(:list_resources),
Methods::RESOURCES_READ => method(:read_resource),
Methods::RESOURCES_READ => method(:read_resource_no_content),
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
Methods::TOOLS_LIST => method(:list_tools),
Methods::TOOLS_CALL => method(:call_tool),
Methods::PROMPTS_LIST => method(:list_prompts),
Methods::PROMPTS_GET => method(:get_prompt),
Methods::INITIALIZE => method(:init),
Methods::PING => ->(_) { {} },
# No op handlers for currently unsupported methods
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
Methods::LOGGING_SET_LEVEL => ->(_) {},
}
end
@ -55,6 +72,16 @@ module ModelContextProtocol
end
end
def define_tool(name: nil, description: nil, input_schema: nil, annotations: nil, &block)
tool = Tool.define(name:, description:, input_schema:, annotations:, &block)
@tools[tool.name_value] = tool
end
def define_prompt(name: nil, description: nil, arguments: [], &block)
prompt = Prompt.define(name:, description:, arguments:, &block)
@prompts[prompt.name_value] = prompt
end
def resources_list_handler(&block)
@handlers[Methods::RESOURCES_LIST] = block
end
@ -63,6 +90,10 @@ module ModelContextProtocol
@handlers[Methods::RESOURCES_READ] = block
end
def resources_templates_list_handler(&block)
@handlers[Methods::RESOURCES_TEMPLATES_LIST] = block
end
def tools_list_handler(&block)
@handlers[Methods::TOOLS_LIST] = block
end
@ -88,6 +119,8 @@ module ModelContextProtocol
return
end
Methods.ensure_capability!(method, capabilities)
->(params) {
instrument_call(method) do
case method
@ -97,6 +130,10 @@ module ModelContextProtocol
{ prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
when Methods::RESOURCES_LIST
{ resources: @handlers[Methods::RESOURCES_LIST].call(params) }
when Methods::RESOURCES_READ
{ contents: @handlers[Methods::RESOURCES_READ].call(params) }
when Methods::RESOURCES_TEMPLATES_LIST
{ resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
else
@handlers[method].call(params)
end
@ -113,14 +150,6 @@ module ModelContextProtocol
}
end
def capabilities
@capabilities ||= {
prompts: {},
resources: {},
tools: {},
}
end
def server_info
@server_info ||= {
name:,
@ -151,10 +180,25 @@ module ModelContextProtocol
raise RequestHandlerError.new("Tool not found #{tool_name}", request, error_type: :tool_not_found)
end
arguments = request[:arguments]
add_instrumentation_data(tool_name:)
if tool.input_schema&.missing_required_arguments?(arguments)
add_instrumentation_data(error: :missing_required_arguments)
raise RequestHandlerError.new(
"Missing required arguments: #{tool.input_schema.missing_required_arguments(arguments).join(", ")}",
request,
error_type: :missing_required_arguments,
)
end
begin
tool.call(**request[:arguments], server_context:).to_h
call_params = tool.method(:call).parameters.flatten
if call_params.include?(:server_context)
tool.call(**arguments.transform_keys(&:to_sym), server_context:).to_h
else
tool.call(**arguments.transform_keys(&:to_sym)).to_h
end
rescue => e
raise RequestHandlerError.new("Internal error calling tool #{tool_name}", request, original_error: e)
end
@ -188,18 +232,17 @@ module ModelContextProtocol
@resources.map(&:to_h)
end
def read_resource(request)
# Server implementation should set read_resource_handler to override no-op default
def read_resource_no_content(request)
add_instrumentation_data(method: Methods::RESOURCES_READ)
resource_uri = request[:uri]
add_instrumentation_data(resource_uri: request[:uri])
[]
end
resource = @resource_index[resource_uri]
unless resource
add_instrumentation_data(error: :resource_not_found)
raise RequestHandlerError.new("Resource not found #{resource_uri}", request, error_type: :resource_not_found)
end
def list_resource_templates(request)
add_instrumentation_data(method: Methods::RESOURCES_TEMPLATES_LIST)
add_instrumentation_data(resource_uri:)
resource.to_h
@resource_templates.map(&:to_h)
end
def report_exception(exception, server_context = {})

View file

@ -2,41 +2,6 @@
module ModelContextProtocol
class Tool
class Response
attr_reader :content, :is_error
def initialize(content, is_error = false)
@content = content
@is_error = is_error
end
def to_h
{ content:, isError: is_error }.compact
end
end
class Annotations
attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint
def initialize(title: nil, read_only_hint: nil, destructive_hint: nil, idempotent_hint: nil, open_world_hint: nil)
@title = title
@read_only_hint = read_only_hint
@destructive_hint = destructive_hint
@idempotent_hint = idempotent_hint
@open_world_hint = open_world_hint
end
def to_h
{
title:,
readOnlyHint: read_only_hint,
destructiveHint: destructive_hint,
idempotentHint: idempotent_hint,
openWorldHint: open_world_hint,
}.compact
end
end
class << self
NOT_SET = Object.new
@ -52,7 +17,7 @@ module ModelContextProtocol
result = {
name: name_value,
description: description_value,
inputSchema: input_schema_value,
inputSchema: input_schema_value.to_h,
}
result[:annotations] = annotations_value.to_h if annotations_value
result
@ -88,8 +53,12 @@ module ModelContextProtocol
def input_schema(value = NOT_SET)
if value == NOT_SET
@input_schema_value
else
input_schema_value
elsif value.is_a?(Hash)
properties = value[:properties] || value["properties"] || {}
required = value[:required] || value["required"] || []
@input_schema_value = InputSchema.new(properties:, required:)
elsif value.is_a?(InputSchema)
@input_schema_value = value
end
end
@ -108,9 +77,7 @@ module ModelContextProtocol
description description
input_schema input_schema
self.annotations(annotations) if annotations
define_singleton_method(:call) do |*args, server_context:|
instance_exec(*args, server_context:, &block)
end
define_singleton_method(:call, &block) if block
end
end
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
module ModelContextProtocol
class Tool
class Annotations
attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint
def initialize(title: nil, read_only_hint: nil, destructive_hint: nil, idempotent_hint: nil, open_world_hint: nil)
@title = title
@read_only_hint = read_only_hint
@destructive_hint = destructive_hint
@idempotent_hint = idempotent_hint
@open_world_hint = open_world_hint
end
def to_h
{
title:,
readOnlyHint: read_only_hint,
destructiveHint: destructive_hint,
idempotentHint: idempotent_hint,
openWorldHint: open_world_hint,
}.compact
end
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module ModelContextProtocol
class Tool
class InputSchema
attr_reader :properties, :required
def initialize(properties: {}, required: [])
@properties = properties
@required = required.map(&:to_sym)
end
def to_h
{ type: "object", properties:, required: }
end
def missing_required_arguments?(arguments)
missing_required_arguments(arguments).any?
end
def missing_required_arguments(arguments)
(required - arguments.keys.map(&:to_sym))
end
end
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
module ModelContextProtocol
class Tool
class Response
attr_reader :content, :is_error
def initialize(content, is_error = false)
@content = content
@is_error = is_error
end
def to_h
{ content:, isError: is_error }.compact
end
end
end
end

View file

@ -1,5 +1,5 @@
# frozen_string_literal: true
module ModelContextProtocol
VERSION = "0.6.0"
VERSION = "0.7.0"
end