mcp 0.1.0 → 0.3.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,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "logger"
7
+
8
+ # Logger for client operations
9
+ logger = Logger.new($stdout)
10
+ logger.formatter = proc do |severity, datetime, _progname, msg|
11
+ "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
12
+ end
13
+
14
+ # Server configuration
15
+ SERVER_URL = "http://localhost:9393/mcp"
16
+ PROTOCOL_VERSION = "2024-11-05"
17
+
18
+ # Helper method to make JSON-RPC requests
19
+ def make_request(session_id, method, params = {}, id = nil)
20
+ uri = URI(SERVER_URL)
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+
23
+ request = Net::HTTP::Post.new(uri)
24
+ request["Content-Type"] = "application/json"
25
+ request["Mcp-Session-Id"] = session_id if session_id
26
+
27
+ body = {
28
+ jsonrpc: "2.0",
29
+ method: method,
30
+ params: params,
31
+ id: id || SecureRandom.uuid,
32
+ }
33
+
34
+ request.body = body.to_json
35
+ response = http.request(request)
36
+
37
+ {
38
+ status: response.code,
39
+ headers: response.to_hash,
40
+ body: JSON.parse(response.body),
41
+ }
42
+ rescue => e
43
+ { error: e.message }
44
+ end
45
+
46
+ # Connect to SSE stream
47
+ def connect_sse(session_id, logger)
48
+ uri = URI(SERVER_URL)
49
+
50
+ logger.info("Connecting to SSE stream...")
51
+
52
+ Net::HTTP.start(uri.host, uri.port) do |http|
53
+ request = Net::HTTP::Get.new(uri)
54
+ request["Mcp-Session-Id"] = session_id
55
+ request["Accept"] = "text/event-stream"
56
+ request["Cache-Control"] = "no-cache"
57
+
58
+ http.request(request) do |response|
59
+ if response.code == "200"
60
+ logger.info("SSE stream connected successfully")
61
+
62
+ response.read_body do |chunk|
63
+ chunk.split("\n").each do |line|
64
+ if line.start_with?("data: ")
65
+ data = line[6..-1]
66
+ begin
67
+ logger.info("SSE data: #{data}")
68
+ rescue JSON::ParserError
69
+ logger.debug("Non-JSON SSE data: #{data}")
70
+ end
71
+ elsif line.start_with?(": ")
72
+ logger.debug("SSE keepalive received: #{line}")
73
+ end
74
+ end
75
+ end
76
+ else
77
+ logger.error("Failed to connect to SSE: #{response.code} #{response.message}")
78
+ end
79
+ end
80
+ end
81
+ rescue Interrupt
82
+ logger.info("SSE connection interrupted by user")
83
+ rescue => e
84
+ logger.error("SSE connection error: #{e.message}")
85
+ end
86
+
87
+ # Main client flow
88
+ def main
89
+ logger = Logger.new($stdout)
90
+ logger.formatter = proc do |severity, datetime, _progname, msg|
91
+ "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
92
+ end
93
+
94
+ puts "=== MCP SSE Test Client ==="
95
+
96
+ # Step 1: Initialize session
97
+ logger.info("Initializing session...")
98
+
99
+ init_response = make_request(
100
+ nil,
101
+ "initialize",
102
+ {
103
+ protocolVersion: PROTOCOL_VERSION,
104
+ capabilities: {},
105
+ clientInfo: {
106
+ name: "sse-test-client",
107
+ version: "1.0",
108
+ },
109
+ },
110
+ "init-1",
111
+ )
112
+
113
+ if init_response[:error]
114
+ logger.error("Failed to initialize: #{init_response[:error]}")
115
+ exit(1)
116
+ end
117
+
118
+ session_id = init_response[:headers]["mcp-session-id"]&.first
119
+
120
+ if session_id.nil?
121
+ logger.error("No session ID received")
122
+ exit(1)
123
+ end
124
+
125
+ logger.info("Session initialized: #{session_id}")
126
+ logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}")
127
+
128
+ # Step 2: Start SSE connection in a separate thread
129
+ sse_thread = Thread.new { connect_sse(session_id, logger) }
130
+
131
+ # Give SSE time to connect
132
+ sleep(1)
133
+
134
+ # Step 3: Interactive menu
135
+ loop do
136
+ puts <<~MESSAGE.chomp
137
+
138
+ === Available Actions ===
139
+ 1. Send custom notification
140
+ 2. Test echo
141
+ 3. List tools
142
+ 0. Exit
143
+
144
+ Choose an action:#{" "}
145
+ MESSAGE
146
+
147
+ choice = gets.chomp
148
+
149
+ case choice
150
+ when "1"
151
+ print("Enter notification message: ")
152
+ message = gets.chomp
153
+ print("Enter delay in seconds (0 for immediate): ")
154
+ delay = gets.chomp.to_f
155
+
156
+ response = make_request(
157
+ session_id,
158
+ "tools/call",
159
+ {
160
+ name: "notification_tool",
161
+ arguments: {
162
+ message: message,
163
+ delay: delay,
164
+ },
165
+ },
166
+ )
167
+ if response[:body]["accepted"]
168
+ logger.info("Notification sent successfully")
169
+ else
170
+ logger.error("Error: #{response[:body]["error"]}")
171
+ end
172
+ when "2"
173
+ print("Enter message to echo: ")
174
+ message = gets.chomp
175
+ make_request(session_id, "tools/call", { name: "echo", arguments: { message: message } })
176
+ when "3"
177
+ make_request(session_id, "tools/list")
178
+ when "0"
179
+ logger.info("Exiting...")
180
+ break
181
+ else
182
+ puts "Invalid choice"
183
+ end
184
+ end
185
+
186
+ # Clean up
187
+ sse_thread.kill if sse_thread.alive?
188
+
189
+ # Close session
190
+ logger.info("Closing session...")
191
+ make_request(session_id, "close")
192
+ logger.info("Session closed")
193
+ rescue Interrupt
194
+ logger.info("Client interrupted by user")
195
+ rescue => e
196
+ logger.error("Client error: #{e.message}")
197
+ logger.error(e.backtrace.join("\n"))
198
+ end
199
+
200
+ # Run the client
201
+ if __FILE__ == $PROGRAM_NAME
202
+ main
203
+ end
@@ -0,0 +1,173 @@
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 <<~MESSAGE
140
+ === MCP Streaming HTTP Test Server ===
141
+
142
+ Starting server on http://localhost:9393
143
+
144
+ Available Tools:
145
+ 1. NotificationTool - Returns messages that are sent via SSE when stream is active"
146
+ 2. echo - Simple echo tool
147
+
148
+ Testing SSE:
149
+
150
+ 1. Initialize session:
151
+ curl -i http://localhost:9393 \\
152
+ --json '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}'
153
+
154
+ 2. Connect SSE stream (use the session ID from step 1):"
155
+ curl -i -N -H "Mcp-Session-Id: YOUR_SESSION_ID" http://localhost:9393
156
+
157
+ 3. In another terminal, test tools (responses will be sent via SSE if stream is active):
158
+
159
+ Echo tool:
160
+ curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\
161
+ --json '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"echo","arguments":{"message":"Hello SSE!"}}}'
162
+
163
+ Notification tool (with 2 second delay):
164
+ curl -i http://localhost:9393 -H "Mcp-Session-Id: YOUR_SESSION_ID" \\
165
+ --json '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}'
166
+
167
+ Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return {"accepted": true}
168
+
169
+ Press Ctrl+C to stop the server
170
+ MESSAGE
171
+
172
+ # Start the server
173
+ Rackup::Handler.get("puma").run(rack_app, Port: 9393, Host: "localhost")
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ class HTTP
6
+ attr_reader :url
7
+
8
+ def initialize(url:, headers: {})
9
+ @url = url
10
+ @headers = headers
11
+ end
12
+
13
+ def send_request(request:)
14
+ method = request[:method] || request["method"]
15
+ params = request[:params] || request["params"]
16
+
17
+ client.post("", request).body
18
+ rescue Faraday::BadRequestError => e
19
+ raise RequestHandlerError.new(
20
+ "The #{method} request is invalid",
21
+ { method:, params: },
22
+ error_type: :bad_request,
23
+ original_error: e,
24
+ )
25
+ rescue Faraday::UnauthorizedError => e
26
+ raise RequestHandlerError.new(
27
+ "You are unauthorized to make #{method} requests",
28
+ { method:, params: },
29
+ error_type: :unauthorized,
30
+ original_error: e,
31
+ )
32
+ rescue Faraday::ForbiddenError => e
33
+ raise RequestHandlerError.new(
34
+ "You are forbidden to make #{method} requests",
35
+ { method:, params: },
36
+ error_type: :forbidden,
37
+ original_error: e,
38
+ )
39
+ rescue Faraday::ResourceNotFound => e
40
+ raise RequestHandlerError.new(
41
+ "The #{method} request is not found",
42
+ { method:, params: },
43
+ error_type: :not_found,
44
+ original_error: e,
45
+ )
46
+ rescue Faraday::UnprocessableEntityError => e
47
+ raise RequestHandlerError.new(
48
+ "The #{method} request is unprocessable",
49
+ { method:, params: },
50
+ error_type: :unprocessable_entity,
51
+ original_error: e,
52
+ )
53
+ rescue Faraday::Error => e # Catch-all
54
+ raise RequestHandlerError.new(
55
+ "Internal error handling #{method} request",
56
+ { method:, params: },
57
+ error_type: :internal_error,
58
+ original_error: e,
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :headers
65
+
66
+ def client
67
+ require_faraday!
68
+ @client ||= Faraday.new(url) do |faraday|
69
+ faraday.request(:json)
70
+ faraday.response(:json)
71
+ faraday.response(:raise_error)
72
+
73
+ headers.each do |key, value|
74
+ faraday.headers[key] = value
75
+ end
76
+ end
77
+ end
78
+
79
+ def require_faraday!
80
+ require "faraday"
81
+ rescue LoadError
82
+ raise LoadError, "The 'faraday' gem is required to use the MCP client HTTP transport. " \
83
+ "Add it to your Gemfile: gem 'faraday', '>= 2.0'" \
84
+ "See https://rubygems.org/gems/faraday for more details."
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ class Tool
6
+ attr_reader :name, :description, :input_schema, :output_schema
7
+
8
+ def initialize(name:, description:, input_schema:, output_schema: nil)
9
+ @name = name
10
+ @description = description
11
+ @input_schema = input_schema
12
+ @output_schema = output_schema
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/mcp/client.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ # Initializes a new MCP::Client instance.
6
+ #
7
+ # @param transport [Object] The transport object to use for communication with the server.
8
+ # The transport should be a duck type that responds to `send_request`. See the README for more details.
9
+ #
10
+ # @example
11
+ # transport = MCP::Client::HTTP.new(url: "http://localhost:3000")
12
+ # client = MCP::Client.new(transport: transport)
13
+ def initialize(transport:)
14
+ @transport = transport
15
+ end
16
+
17
+ # The user may want to access additional transport-specific methods/attributes
18
+ # So keeping it public
19
+ attr_reader :transport
20
+
21
+ # Returns the list of tools available from the server.
22
+ # Each call will make a new request – the result is not cached.
23
+ #
24
+ # @return [Array<MCP::Client::Tool>] An array of available tools.
25
+ #
26
+ # @example
27
+ # tools = client.tools
28
+ # tools.each do |tool|
29
+ # puts tool.name
30
+ # end
31
+ def tools
32
+ response = transport.send_request(request: {
33
+ jsonrpc: JsonRpcHandler::Version::V2_0,
34
+ id: request_id,
35
+ method: "tools/list",
36
+ })
37
+
38
+ response.dig("result", "tools")&.map do |tool|
39
+ Tool.new(
40
+ name: tool["name"],
41
+ description: tool["description"],
42
+ input_schema: tool["inputSchema"],
43
+ )
44
+ end || []
45
+ end
46
+
47
+ # Calls a tool via the transport layer.
48
+ #
49
+ # @param tool [MCP::Client::Tool] The tool to be called.
50
+ # @param arguments [Object, nil] The arguments to pass to the tool.
51
+ # @return [Object] The result of the tool call, as returned by the transport.
52
+ #
53
+ # @example
54
+ # tool = client.tools.first
55
+ # result = client.call_tool(tool: tool, arguments: { foo: "bar" })
56
+ #
57
+ # @note
58
+ # The exact requirements for `arguments` are determined by the transport layer in use.
59
+ # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
60
+ def call_tool(tool:, arguments: nil)
61
+ response = transport.send_request(request: {
62
+ jsonrpc: JsonRpcHandler::Version::V2_0,
63
+ id: request_id,
64
+ method: "tools/call",
65
+ params: { name: tool.name, arguments: arguments },
66
+ })
67
+
68
+ response.dig("result", "content")
69
+ end
70
+
71
+ private
72
+
73
+ def request_id
74
+ SecureRandom.uuid
75
+ end
76
+
77
+ class RequestHandlerError < StandardError
78
+ attr_reader :error_type, :original_error, :request
79
+
80
+ def initialize(message, request, error_type: :internal_error, original_error: nil)
81
+ super(message)
82
+ @request = request
83
+ @error_type = error_type
84
+ @original_error = original_error
85
+ end
86
+ end
87
+ end
88
+ end
@@ -2,14 +2,25 @@
2
2
 
3
3
  module MCP
4
4
  class Configuration
5
- DEFAULT_PROTOCOL_VERSION = "2024-11-05"
5
+ DEFAULT_PROTOCOL_VERSION = "2025-06-18"
6
+ SUPPORTED_PROTOCOL_VERSIONS = [DEFAULT_PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]
6
7
 
7
- attr_writer :exception_reporter, :instrumentation_callback, :protocol_version
8
+ attr_writer :exception_reporter, :instrumentation_callback, :protocol_version, :validate_tool_call_arguments
8
9
 
9
- def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil)
10
+ def initialize(exception_reporter: nil, instrumentation_callback: nil, protocol_version: nil,
11
+ validate_tool_call_arguments: true)
10
12
  @exception_reporter = exception_reporter
11
13
  @instrumentation_callback = instrumentation_callback
12
14
  @protocol_version = protocol_version
15
+ if protocol_version && !SUPPORTED_PROTOCOL_VERSIONS.include?(protocol_version)
16
+ message = "protocol_version must be #{SUPPORTED_PROTOCOL_VERSIONS[0...-1].join(", ")}, or #{SUPPORTED_PROTOCOL_VERSIONS[-1]}"
17
+ raise ArgumentError, message
18
+ end
19
+ unless validate_tool_call_arguments.is_a?(TrueClass) || validate_tool_call_arguments.is_a?(FalseClass)
20
+ raise ArgumentError, "validate_tool_call_arguments must be a boolean"
21
+ end
22
+
23
+ @validate_tool_call_arguments = validate_tool_call_arguments
13
24
  end
14
25
 
15
26
  def protocol_version
@@ -36,6 +47,12 @@ module MCP
36
47
  !@instrumentation_callback.nil?
37
48
  end
38
49
 
50
+ attr_reader :validate_tool_call_arguments
51
+
52
+ def validate_tool_call_arguments?
53
+ !!@validate_tool_call_arguments
54
+ end
55
+
39
56
  def merge(other)
40
57
  return self if other.nil?
41
58
 
@@ -54,11 +71,13 @@ module MCP
54
71
  else
55
72
  @protocol_version
56
73
  end
74
+ validate_tool_call_arguments = other.validate_tool_call_arguments
57
75
 
58
76
  Configuration.new(
59
77
  exception_reporter:,
60
78
  instrumentation_callback:,
61
79
  protocol_version:,
80
+ validate_tool_call_arguments:,
62
81
  )
63
82
  end
64
83