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.
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "mcp"
5
+ require "mcp/server/transports/streamable_http_transport"
6
+ require "rack"
7
+ require "rackup"
8
+ require "json"
9
+ require "logger"
10
+
11
+ # Create a logger for SSE-specific logging
12
+ sse_logger = Logger.new($stdout)
13
+ sse_logger.formatter = proc do |severity, datetime, _progname, msg|
14
+ "[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
15
+ end
16
+
17
+ # Tool that returns a response that will be sent via SSE if a stream is active
18
+ class NotificationTool < MCP::Tool
19
+ tool_name "notification_tool"
20
+ description "Returns a notification message that will be sent via SSE if stream is active"
21
+ input_schema(
22
+ properties: {
23
+ message: { type: "string", description: "Message to send via SSE" },
24
+ delay: { type: "number", description: "Delay in seconds before returning (optional)" },
25
+ },
26
+ required: ["message"],
27
+ )
28
+
29
+ class << self
30
+ attr_accessor :logger
31
+
32
+ def call(message:, delay: 0)
33
+ sleep(delay) if delay > 0
34
+
35
+ logger&.info("Returning notification message: #{message}")
36
+
37
+ MCP::Tool::Response.new([{
38
+ type: "text",
39
+ text: "Notification: #{message} (timestamp: #{Time.now.iso8601})",
40
+ }])
41
+ end
42
+ end
43
+ end
44
+
45
+ # Create the server
46
+ server = MCP::Server.new(
47
+ name: "sse_test_server",
48
+ tools: [NotificationTool],
49
+ prompts: [],
50
+ resources: [],
51
+ )
52
+
53
+ # Set logger for tools
54
+ NotificationTool.logger = sse_logger
55
+
56
+ # Add a simple echo tool for basic testing
57
+ server.define_tool(
58
+ name: "echo",
59
+ description: "Simple echo tool",
60
+ input_schema: { properties: { message: { type: "string" } }, required: ["message"] },
61
+ ) do |message:|
62
+ MCP::Tool::Response.new([{ type: "text", text: "Echo: #{message}" }])
63
+ end
64
+
65
+ # Create the Streamable HTTP transport
66
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
67
+ server.transport = transport
68
+
69
+ # Create a logger for MCP request/response logging
70
+ mcp_logger = Logger.new($stdout)
71
+ mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg|
72
+ "[MCP] #{msg}\n"
73
+ end
74
+
75
+ # Create the Rack application
76
+ app = proc do |env|
77
+ request = Rack::Request.new(env)
78
+
79
+ # Log request details
80
+ if request.post?
81
+ body = request.body.read
82
+ request.body.rewind
83
+ begin
84
+ parsed_body = JSON.parse(body)
85
+ mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})")
86
+
87
+ # Log SSE-specific setup
88
+ if parsed_body["method"] == "initialize"
89
+ sse_logger.info("New client initializing session")
90
+ end
91
+ rescue JSON::ParserError
92
+ mcp_logger.warn("Invalid JSON in request")
93
+ end
94
+ elsif request.get?
95
+ session_id = request.env["HTTP_MCP_SESSION_ID"] ||
96
+ Rack::Utils.parse_query(request.env["QUERY_STRING"])["sessionId"]
97
+ sse_logger.info("SSE connection request for session: #{session_id}")
98
+ end
99
+
100
+ # Handle the request
101
+ response = transport.handle_request(request)
102
+
103
+ # Log response details
104
+ status, headers, body = response
105
+ if body.is_a?(Array) && !body.empty? && request.post?
106
+ begin
107
+ parsed_response = JSON.parse(body.first)
108
+ if parsed_response["error"]
109
+ mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
110
+ elsif parsed_response["accepted"]
111
+ # Response was sent via SSE
112
+ sse_logger.info("Response sent via SSE stream")
113
+ else
114
+ mcp_logger.info("Response: success (id: #{parsed_response["id"]})")
115
+
116
+ # Log session ID for initialization
117
+ if headers["Mcp-Session-Id"]
118
+ sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}")
119
+ end
120
+ end
121
+ rescue JSON::ParserError
122
+ mcp_logger.warn("Invalid JSON in response")
123
+ end
124
+ elsif request.get? && status == 200
125
+ sse_logger.info("SSE stream established")
126
+ end
127
+
128
+ response
129
+ end
130
+
131
+ # Build the Rack application with middleware
132
+ rack_app = Rack::Builder.new do
133
+ use(Rack::CommonLogger, Logger.new($stdout))
134
+ use(Rack::ShowExceptions)
135
+ run(app)
136
+ end
137
+
138
+ # Print usage instructions
139
+ puts "=== MCP Streaming HTTP Test Server ==="
140
+ puts ""
141
+ puts "Starting server on http://localhost:9393"
142
+ puts ""
143
+ puts "Available Tools:"
144
+ puts "1. NotificationTool - Returns messages that are sent via SSE when stream is active"
145
+ puts "2. echo - Simple echo tool"
146
+ puts ""
147
+ puts "Testing SSE:"
148
+ puts ""
149
+ puts "1. Initialize session:"
150
+ puts " curl -i http://localhost:9393 \\"
151
+ puts ' --json \'{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}\''
152
+ puts ""
153
+ puts "2. Connect SSE stream (use the session ID from step 1):"
154
+ puts ' curl -i -N -H "Mcp-Session-Id: YOUR_SESSION_ID" http://localhost:9393'
155
+ puts ""
156
+ puts "3. In another terminal, test tools (responses will be sent via SSE if stream is active):"
157
+ puts ""
158
+ puts " Echo tool:"
159
+ puts ' curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\'
160
+ puts ' --json \'{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"echo","arguments":{"message":"Hello SSE!"}}}\''
161
+ puts ""
162
+ puts " Notification tool (with 2 second delay):"
163
+ puts ' curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\'
164
+ puts ' --json \'{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}\''
165
+ puts ""
166
+ puts "Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return {\"accepted\": true}"
167
+ puts ""
168
+ puts "Press Ctrl+C to stop the server"
169
+ puts ""
170
+
171
+ # Start the server
172
+ Rackup::Handler.get("puma").run(rack_app, Port: 9393, Host: "localhost")
@@ -4,12 +4,18 @@ module MCP
4
4
  class Configuration
5
5
  DEFAULT_PROTOCOL_VERSION = "2024-11-05"
6
6
 
7
- attr_writer :exception_reporter, :instrumentation_callback, :protocol_version
7
+ attr_writer :exception_reporter, :instrumentation_callback, :protocol_version, :validate_tool_call_arguments
8
8
 
9
- def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil)
9
+ def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil,
10
+ validate_tool_call_arguments: true)
10
11
  @exception_reporter = exception_reporter
11
12
  @instrumentation_callback = instrumentation_callback
12
13
  @protocol_version = protocol_version
14
+ unless validate_tool_call_arguments.is_a?(TrueClass) || validate_tool_call_arguments.is_a?(FalseClass)
15
+ raise ArgumentError, "validate_tool_call_arguments must be a boolean"
16
+ end
17
+
18
+ @validate_tool_call_arguments = validate_tool_call_arguments
13
19
  end
14
20
 
15
21
  def protocol_version
@@ -36,6 +42,12 @@ module MCP
36
42
  !@instrumentation_callback.nil?
37
43
  end
38
44
 
45
+ attr_reader :validate_tool_call_arguments
46
+
47
+ def validate_tool_call_arguments?
48
+ !!@validate_tool_call_arguments
49
+ end
50
+
39
51
  def merge(other)
40
52
  return self if other.nil?
41
53
 
@@ -54,11 +66,13 @@ module MCP
54
66
  else
55
67
  @protocol_version
56
68
  end
69
+ validate_tool_call_arguments = other.validate_tool_call_arguments
57
70
 
58
71
  Configuration.new(
59
72
  exception_reporter:,
60
73
  instrumentation_callback:,
61
74
  protocol_version:,
75
+ validate_tool_call_arguments:,
62
76
  )
63
77
  end
64
78
 
data/lib/mcp/methods.rb CHANGED
@@ -19,8 +19,20 @@ module MCP
19
19
  TOOLS_CALL = "tools/call"
20
20
  TOOLS_LIST = "tools/list"
21
21
 
22
+ ROOTS_LIST = "roots/list"
22
23
  SAMPLING_CREATE_MESSAGE = "sampling/createMessage"
23
24
 
25
+ # Notification methods
26
+ NOTIFICATIONS_INITIALIZED = "notifications/initialized"
27
+ NOTIFICATIONS_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"
28
+ NOTIFICATIONS_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"
29
+ NOTIFICATIONS_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"
30
+ NOTIFICATIONS_RESOURCES_UPDATED = "notifications/resources/updated"
31
+ NOTIFICATIONS_ROOTS_LIST_CHANGED = "notifications/roots/list_changed"
32
+ NOTIFICATIONS_MESSAGE = "notifications/message"
33
+ NOTIFICATIONS_PROGRESS = "notifications/progress"
34
+ NOTIFICATIONS_CANCELLED = "notifications/cancelled"
35
+
24
36
  class MissingRequiredCapabilityError < StandardError
25
37
  attr_reader :method
26
38
  attr_reader :capability
@@ -32,41 +44,51 @@ module MCP
32
44
  end
33
45
  end
34
46
 
35
- extend self
36
-
37
- def ensure_capability!(method, capabilities)
38
- case method
39
- when PROMPTS_GET, PROMPTS_LIST
40
- unless capabilities[:prompts]
41
- raise MissingRequiredCapabilityError.new(method, :prompts)
42
- end
43
- when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ, RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE
44
- unless capabilities[:resources]
45
- raise MissingRequiredCapabilityError.new(method, :resources)
47
+ class << self
48
+ def ensure_capability!(method, capabilities)
49
+ case method
50
+ when PROMPTS_GET, PROMPTS_LIST
51
+ require_capability!(method, capabilities, :prompts)
52
+ when NOTIFICATIONS_PROMPTS_LIST_CHANGED
53
+ require_capability!(method, capabilities, :prompts)
54
+ require_capability!(method, capabilities, :prompts, :listChanged)
55
+ when RESOURCES_LIST, RESOURCES_TEMPLATES_LIST, RESOURCES_READ
56
+ require_capability!(method, capabilities, :resources)
57
+ when NOTIFICATIONS_RESOURCES_LIST_CHANGED
58
+ require_capability!(method, capabilities, :resources)
59
+ require_capability!(method, capabilities, :resources, :listChanged)
60
+ when RESOURCES_SUBSCRIBE, RESOURCES_UNSUBSCRIBE, NOTIFICATIONS_RESOURCES_UPDATED
61
+ require_capability!(method, capabilities, :resources)
62
+ require_capability!(method, capabilities, :resources, :subscribe)
63
+ when TOOLS_CALL, TOOLS_LIST
64
+ require_capability!(method, capabilities, :tools)
65
+ when NOTIFICATIONS_TOOLS_LIST_CHANGED
66
+ require_capability!(method, capabilities, :tools)
67
+ require_capability!(method, capabilities, :tools, :listChanged)
68
+ when LOGGING_SET_LEVEL, NOTIFICATIONS_MESSAGE
69
+ require_capability!(method, capabilities, :logging)
70
+ when COMPLETION_COMPLETE
71
+ require_capability!(method, capabilities, :completions)
72
+ when ROOTS_LIST
73
+ require_capability!(method, capabilities, :roots)
74
+ when NOTIFICATIONS_ROOTS_LIST_CHANGED
75
+ require_capability!(method, capabilities, :roots)
76
+ require_capability!(method, capabilities, :roots, :listChanged)
77
+ when SAMPLING_CREATE_MESSAGE
78
+ require_capability!(method, capabilities, :sampling)
79
+ when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED
80
+ # No specific capability required for initialize, ping, progress or cancelled
46
81
  end
82
+ end
47
83
 
48
- if method == RESOURCES_SUBSCRIBE && !capabilities[:resources][:subscribe]
49
- raise MissingRequiredCapabilityError.new(method, :resources_subscribe)
50
- end
51
- when TOOLS_CALL, TOOLS_LIST
52
- unless capabilities[:tools]
53
- raise MissingRequiredCapabilityError.new(method, :tools)
54
- end
55
- when SAMPLING_CREATE_MESSAGE
56
- unless capabilities[:sampling]
57
- raise MissingRequiredCapabilityError.new(method, :sampling)
58
- end
59
- when COMPLETION_COMPLETE
60
- unless capabilities[:completions]
61
- raise MissingRequiredCapabilityError.new(method, :completions)
62
- end
63
- when LOGGING_SET_LEVEL
64
- # Logging is unsupported by the Server
65
- unless capabilities[:logging]
66
- raise MissingRequiredCapabilityError.new(method, :logging)
67
- end
68
- when INITIALIZE, PING
69
- # No specific capability required for initialize or ping
84
+ private
85
+
86
+ def require_capability!(method, capabilities, *keys)
87
+ name = keys.join(".") # :resources, :subscribe -> "resources.subscribe"
88
+ has_capability = capabilities.dig(*keys)
89
+ return if has_capability
90
+
91
+ raise MissingRequiredCapabilityError.new(method, name)
70
92
  end
71
93
  end
72
94
  end
data/lib/mcp/prompt.rb CHANGED
@@ -9,7 +9,7 @@ module MCP
9
9
  attr_reader :description_value
10
10
  attr_reader :arguments_value
11
11
 
12
- def template(args, server_context:)
12
+ def template(args, server_context: nil)
13
13
  raise NotImplementedError, "Subclasses must implement template"
14
14
  end
15
15
 
@@ -57,7 +57,7 @@ module MCP
57
57
  prompt_name name
58
58
  description description
59
59
  arguments arguments
60
- define_singleton_method(:template) do |args, server_context:|
60
+ define_singleton_method(:template) do |args, server_context: nil|
61
61
  instance_exec(args, server_context:, &block)
62
62
  end
63
63
  end
data/lib/mcp/resource.rb CHANGED
@@ -5,7 +5,7 @@ module MCP
5
5
  class Resource
6
6
  attr_reader :uri, :name, :description, :mime_type
7
7
 
8
- def initialize(uri:, name:, description:, mime_type:)
8
+ def initialize(uri:, name:, description: nil, mime_type: nil)
9
9
  @uri = uri
10
10
  @name = name
11
11
  @description = description
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Server
5
+ class Capabilities
6
+ def initialize(capabilities_hash = nil)
7
+ @completions = nil
8
+ @experimental = nil
9
+ @logging = nil
10
+ @prompts = nil
11
+ @resources = nil
12
+ @tools = nil
13
+
14
+ if capabilities_hash
15
+ support_completions if capabilities_hash.key?(:completions)
16
+ support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
17
+ support_logging if capabilities_hash.key?(:logging)
18
+
19
+ if capabilities_hash.key?(:prompts)
20
+ support_prompts
21
+ prompts_config = capabilities_hash[:prompts] || {}
22
+ support_prompts_list_changed if prompts_config[:listChanged]
23
+ end
24
+
25
+ if capabilities_hash.key?(:resources)
26
+ support_resources
27
+ resources_config = capabilities_hash[:resources] || {}
28
+ support_resources_list_changed if resources_config[:listChanged]
29
+ support_resources_subscribe if resources_config[:subscribe]
30
+ end
31
+
32
+ if capabilities_hash.key?(:tools)
33
+ support_tools
34
+ tools_config = capabilities_hash[:tools] || {}
35
+ support_tools_list_changed if tools_config[:listChanged]
36
+ end
37
+ end
38
+ end
39
+
40
+ def support_completions
41
+ @completions ||= {}
42
+ end
43
+
44
+ def support_experimental(config = {})
45
+ @experimental = config || {}
46
+ end
47
+
48
+ def support_logging
49
+ @logging ||= {}
50
+ end
51
+
52
+ def support_prompts
53
+ @prompts ||= {}
54
+ end
55
+
56
+ def support_prompts_list_changed
57
+ support_prompts
58
+ @prompts[:listChanged] = true
59
+ end
60
+
61
+ def support_resources
62
+ @resources ||= {}
63
+ end
64
+
65
+ def support_resources_list_changed
66
+ support_resources
67
+ @resources[:listChanged] = true
68
+ end
69
+
70
+ def support_resources_subscribe
71
+ support_resources
72
+ @resources[:subscribe] = true
73
+ end
74
+
75
+ def support_tools
76
+ @tools ||= {}
77
+ end
78
+
79
+ def support_tools_list_changed
80
+ support_tools
81
+ @tools[:listChanged] = true
82
+ end
83
+
84
+ def to_h
85
+ {
86
+ completions: @completions,
87
+ experimental: @experimental,
88
+ logging: @logging,
89
+ prompts: @prompts,
90
+ resources: @resources,
91
+ tools: @tools,
92
+ }.compact
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../transport"
4
+ require "json"
5
+
6
+ module MCP
7
+ class Server
8
+ module Transports
9
+ class StdioTransport < Transport
10
+ STATUS_INTERRUPTED = Signal.list["INT"] + 128
11
+
12
+ def initialize(server)
13
+ @server = server
14
+ @open = false
15
+ $stdin.set_encoding("UTF-8")
16
+ $stdout.set_encoding("UTF-8")
17
+ super
18
+ end
19
+
20
+ def open
21
+ @open = true
22
+ while @open && (line = $stdin.gets)
23
+ handle_json_request(line.strip)
24
+ end
25
+ rescue Interrupt
26
+ warn("\nExiting...")
27
+
28
+ exit(STATUS_INTERRUPTED)
29
+ end
30
+
31
+ def close
32
+ @open = false
33
+ end
34
+
35
+ def send_response(message)
36
+ json_message = message.is_a?(String) ? message : JSON.generate(message)
37
+ $stdout.puts(json_message)
38
+ $stdout.flush
39
+ end
40
+
41
+ def send_notification(method, params = nil)
42
+ notification = {
43
+ jsonrpc: "2.0",
44
+ method: method,
45
+ }
46
+ notification[:params] = params if params
47
+
48
+ send_response(notification)
49
+ true
50
+ rescue => e
51
+ MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
52
+ false
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end