vector_mcp 0.1.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,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/vector_mcp/transport/stdio.rb
4
+ require "json"
5
+ require_relative "../errors"
6
+ require_relative "../util"
7
+
8
+ module VectorMCP
9
+ module Transport
10
+ # Implements the Model Context Protocol transport over standard input/output (stdio).
11
+ # This transport reads JSON-RPC messages line-by-line from `$stdin` and writes
12
+ # responses/notifications line-by-line to `$stdout`.
13
+ #
14
+ # It is suitable for inter-process communication on the same machine where a parent
15
+ # process spawns an MCP server and communicates with it via its stdio streams.
16
+ class Stdio
17
+ # @return [VectorMCP::Server] The server instance this transport is bound to.
18
+ attr_reader :server
19
+ # @return [Logger] The logger instance, shared with the server.
20
+ attr_reader :logger
21
+
22
+ # Initializes a new Stdio transport.
23
+ #
24
+ # @param server [VectorMCP::Server] The server instance that will handle messages.
25
+ def initialize(server)
26
+ @server = server
27
+ @logger = server.logger
28
+ @input_mutex = Mutex.new
29
+ @output_mutex = Mutex.new
30
+ @running = false
31
+ @input_thread = nil
32
+ end
33
+
34
+ # Starts the stdio transport, listening for input and processing messages.
35
+ # This method will block until the input stream is closed or an interrupt is received.
36
+ #
37
+ # @return [void]
38
+ def run
39
+ session = create_session
40
+ logger.info("Starting stdio transport")
41
+ @running = true
42
+
43
+ begin
44
+ launch_input_thread(session)
45
+ @input_thread.join
46
+ rescue Interrupt
47
+ logger.info("Interrupted. Shutting down...")
48
+ ensure
49
+ shutdown_transport
50
+ end
51
+ end
52
+
53
+ # Sends a JSON-RPC response message for a given request ID.
54
+ #
55
+ # @param id [String, Integer, nil] The ID of the request being responded to.
56
+ # @param result [Object] The result data for the successful request.
57
+ # @return [void]
58
+ def send_response(id, result)
59
+ response = {
60
+ jsonrpc: "2.0",
61
+ id: id,
62
+ result: result
63
+ }
64
+ write_message(response)
65
+ end
66
+
67
+ # Sends a JSON-RPC error response message.
68
+ #
69
+ # @param id [String, Integer, nil] The ID of the request that caused the error.
70
+ # @param code [Integer] The JSON-RPC error code.
71
+ # @param message [String] A short description of the error.
72
+ # @param data [Object, nil] Additional error data (optional).
73
+ # @return [void]
74
+ def send_error(id, code, message, data = nil)
75
+ error_obj = { code: code, message: message }
76
+ error_obj[:data] = data if data
77
+ response = {
78
+ jsonrpc: "2.0",
79
+ id: id,
80
+ error: error_obj
81
+ }
82
+ write_message(response)
83
+ end
84
+
85
+ # Sends a JSON-RPC notification message (a request without an ID).
86
+ #
87
+ # @param method [String] The method name of the notification.
88
+ # @param params [Hash, Array, nil] The parameters for the notification (optional).
89
+ # @return [void]
90
+ def send_notification(method, params = nil)
91
+ notification = {
92
+ jsonrpc: "2.0",
93
+ method: method
94
+ }
95
+ notification[:params] = params if params
96
+ write_message(notification)
97
+ end
98
+
99
+ # Initiates an immediate shutdown of the transport.
100
+ # Sets the running flag to false and attempts to kill the input reading thread.
101
+ #
102
+ # @return [void]
103
+ def shutdown
104
+ logger.info("Shutdown requested for stdio transport.")
105
+ @running = false
106
+ @input_thread&.kill if @input_thread&.alive?
107
+ end
108
+
109
+ private
110
+
111
+ # The main loop for reading and processing lines from `$stdin`.
112
+ # @api private
113
+ # @param session [VectorMCP::Session] The session object for this connection.
114
+ # @return [void]
115
+ def read_input_loop(session)
116
+ session_id = "stdio-session" # Constant identifier for stdio sessions
117
+
118
+ while @running
119
+ line = read_input_line
120
+ if line.nil?
121
+ logger.info("End of input ($stdin closed). Shutting down stdio transport.")
122
+ break
123
+ end
124
+ next if line.strip.empty?
125
+
126
+ handle_input_line(line, session, session_id)
127
+ end
128
+ end
129
+
130
+ # Reads a single line from `$stdin` in a thread-safe manner.
131
+ # @api private
132
+ # @return [String, nil] The line read from stdin, or nil if EOF is reached.
133
+ def read_input_line
134
+ @input_mutex.synchronize do
135
+ $stdin.gets
136
+ end
137
+ end
138
+
139
+ # Parses a line of input as JSON and dispatches it to the server for handling.
140
+ # Sends back any response data or errors.
141
+ # @api private
142
+ # @param line [String] The line of text read from stdin.
143
+ # @param session [VectorMCP::Session] The current session.
144
+ # @param session_id [String] The identifier for this session.
145
+ # @return [void]
146
+ def handle_input_line(line, session, session_id)
147
+ message = parse_json(line)
148
+ return if message.is_a?(Array) && message.empty? # Error handled in parse_json, indicated by empty array
149
+
150
+ response_data = server.handle_message(message, session, session_id)
151
+ send_response(message["id"], response_data) if message["id"] && response_data
152
+ rescue VectorMCP::ProtocolError => e
153
+ handle_protocol_error(e, message)
154
+ rescue StandardError => e
155
+ handle_unexpected_error(e, message)
156
+ end
157
+
158
+ # --- Run helpers (private) ---
159
+
160
+ # Creates a new session for the stdio connection.
161
+ # @api private
162
+ # @return [VectorMCP::Session] The newly created session.
163
+ def create_session
164
+ VectorMCP::Session.new(
165
+ server_info: server.server_info,
166
+ server_capabilities: server.server_capabilities,
167
+ protocol_version: server.protocol_version
168
+ )
169
+ end
170
+
171
+ # Launches the input reading loop in a new thread.
172
+ # Exits the process on fatal errors within this thread.
173
+ # @api private
174
+ # @param session [VectorMCP::Session] The session to pass to the input loop.
175
+ # @return [void]
176
+ def launch_input_thread(session)
177
+ @input_thread = Thread.new do
178
+ read_input_loop(session)
179
+ rescue StandardError => e
180
+ logger.error("Fatal error in input thread: #{e.message}")
181
+ exit(1) # Critical failure, exit the server process
182
+ end
183
+ end
184
+
185
+ # Cleans up transport resources, ensuring the input thread is stopped.
186
+ # @api private
187
+ # @return [void]
188
+ def shutdown_transport
189
+ @running = false
190
+ @input_thread&.kill if @input_thread&.alive?
191
+ logger.info("Stdio transport shut down")
192
+ end
193
+
194
+ # --- Input helpers (private) ---
195
+
196
+ # Parses a line of text as JSON.
197
+ # If parsing fails, sends a JSON-RPC ParseError and returns an empty array
198
+ # to signal that the error has been handled.
199
+ # @api private
200
+ # @param line [String] The line to parse.
201
+ # @return [Hash, Array] The parsed JSON message as a Hash, or an empty Array if a parse error occurred and was handled.
202
+ def parse_json(line)
203
+ JSON.parse(line.strip)
204
+ rescue JSON::ParserError => e
205
+ logger.error("Failed to parse message as JSON: #{line.strip.inspect} - #{e.message}")
206
+ id = begin
207
+ VectorMCP::Util.extract_id_from_invalid_json(line)
208
+ rescue StandardError
209
+ nil # Best effort, don't let ID extraction fail fatally
210
+ end
211
+ send_error(id, -32_700, "Parse error")
212
+ [] # Signal that error was handled
213
+ end
214
+
215
+ # Handles known VectorMCP::ProtocolError exceptions during message processing.
216
+ # @api private
217
+ # @param error [VectorMCP::ProtocolError] The protocol error instance.
218
+ # @param message [Hash, nil] The original parsed message, if available.
219
+ # @return [void]
220
+ def handle_protocol_error(error, message)
221
+ logger.error("Protocol error processing message: #{error.message} (code: #{error.code}), Details: #{error.details.inspect}")
222
+ request_id = error.request_id || message&.fetch("id", nil)
223
+ send_error(request_id, error.code, error.message, error.details)
224
+ end
225
+
226
+ # Handles unexpected StandardError exceptions during message processing.
227
+ # @api private
228
+ # @param error [StandardError] The unexpected error instance.
229
+ # @param message [Hash, nil] The original parsed message, if available.
230
+ # @return [void]
231
+ def handle_unexpected_error(error, message)
232
+ logger.error("Unexpected error handling message: #{error.message}\n#{error.backtrace.join("\n")}")
233
+ request_id = message&.fetch("id", nil)
234
+ send_error(request_id, -32_603, "Internal error", { details: error.message })
235
+ end
236
+
237
+ # Writes a message hash to `$stdout` as a JSON string, followed by a newline.
238
+ # Ensures the output is flushed. Handles EPIPE errors if stdout closes.
239
+ # @api private
240
+ # @param message [Hash] The message hash to send.
241
+ # @return [void]
242
+ def write_message(message)
243
+ json_msg = message.to_json
244
+ logger.debug { "Sending stdio message: #{json_msg}" }
245
+
246
+ begin
247
+ @output_mutex.synchronize do
248
+ $stdout.puts(json_msg)
249
+ $stdout.flush
250
+ end
251
+ rescue Errno::EPIPE
252
+ logger.error("Output pipe closed. Cannot send message. Shutting down stdio transport.")
253
+ shutdown # Initiate shutdown as we can no longer communicate
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module VectorMCP
7
+ # Provides utility functions for VectorMCP operations, such as data conversion
8
+ # and parsing.
9
+ module Util
10
+ module_function
11
+
12
+ # Converts a given Ruby object into an **array of MCP content items**.
13
+ # This is the *primary* public helper for transforming arbitrary Ruby values
14
+ # into the wire-format expected by the MCP spec.
15
+ #
16
+ # Keys present in each returned hash:
17
+ # * **:type** – Currently always `"text"`; future protocol versions may add rich/binary types.
18
+ # * **:text** – UTF-8 encoded payload.
19
+ # * **:mimeType** – IANA media-type describing `:text` (`"text/plain"`, `"application/json"`, …).
20
+ # * **:uri** – _Optional._ Added downstream (e.g., by {Handlers::Core.read_resource}).
21
+ #
22
+ # The method **never** returns `nil` and **always** returns at least one element.
23
+ #
24
+ # @param input [Object] The Ruby value to convert. Supported types are
25
+ # `String`, `Hash`, `Array`, or any object that responds to `#to_s`.
26
+ # @param mime_type [String] The fallback MIME type for plain-text conversions
27
+ # (defaults to `"text/plain"`).
28
+ # @return [Array<Hash>] A non-empty array whose hashes conform to the MCP
29
+ # `Content` schema.
30
+ #
31
+ # @example Simple string
32
+ # VectorMCP::Util.convert_to_mcp_content("Hello")
33
+ # # => [{type: "text", text: "Hello", mimeType: "text/plain"}]
34
+ #
35
+ # @example Complex object
36
+ # VectorMCP::Util.convert_to_mcp_content({foo: 1})
37
+ # # => [{type: "text", text: "{\"foo\":1}", mimeType: "application/json"}]
38
+ def convert_to_mcp_content(input, mime_type: "text/plain")
39
+ return string_content(input, mime_type) if input.is_a?(String)
40
+ return hash_content(input) if input.is_a?(Hash)
41
+ return array_content(input, mime_type) if input.is_a?(Array)
42
+
43
+ fallback_content(input, mime_type)
44
+ end
45
+
46
+ # --- Conversion helpers (exposed as module functions) ---
47
+
48
+ # Converts a String into an MCP text content item.
49
+ # @param str [String] The string to convert.
50
+ # @param mime_type [String] The MIME type for the content.
51
+ # @return [Array<Hash>] MCP content array with one text item.
52
+ def string_content(str, mime_type)
53
+ [{ type: "text", text: str, mimeType: mime_type }]
54
+ end
55
+
56
+ # Converts a Hash into an MCP content item.
57
+ # If the hash appears to be a pre-formatted MCP content item, it's used directly.
58
+ # Otherwise, it's converted to a JSON string with `application/json` MIME type.
59
+ # @param hash [Hash] The hash to convert.
60
+ # @return [Array<Hash>] MCP content array.
61
+ def hash_content(hash)
62
+ if hash[:type] || hash["type"] # Already in content format
63
+ [hash.transform_keys(&:to_sym)]
64
+ else
65
+ [{ type: "text", text: hash.to_json, mimeType: "application/json" }]
66
+ end
67
+ end
68
+
69
+ # Converts an Array into MCP content items.
70
+ # If all array elements are pre-formatted MCP content items, they are used directly.
71
+ # Otherwise, each item in the array is recursively converted using {#convert_to_mcp_content}.
72
+ # @param arr [Array] The array to convert.
73
+ # @param mime_type [String] The default MIME type for child items if they need conversion.
74
+ # @return [Array<Hash>] MCP content array.
75
+ def array_content(arr, mime_type)
76
+ if arr.all? { |item| item.is_a?(Hash) && (item[:type] || item["type"]) }
77
+ arr.map { |item| item.transform_keys(&:to_sym) }
78
+ else
79
+ # Recursively convert each item, preserving the original mime_type intent for non-structured children.
80
+ arr.flat_map { |item| convert_to_mcp_content(item, mime_type: mime_type) }
81
+ end
82
+ end
83
+
84
+ # Fallback conversion for any other object type to an MCP text content item.
85
+ # Converts the object to its string representation.
86
+ # @param obj [Object] The object to convert.
87
+ # @param mime_type [String] The MIME type for the content.
88
+ # @return [Array<Hash>] MCP content array with one text item.
89
+ def fallback_content(obj, mime_type)
90
+ [{ type: "text", text: obj.to_s, mimeType: mime_type }]
91
+ end
92
+
93
+ module_function :string_content, :hash_content, :array_content, :fallback_content
94
+
95
+ # Extracts an ID from a potentially malformed JSON string using regex.
96
+ # This is a best-effort attempt, primarily for error reporting when full JSON parsing fails.
97
+ # It looks for patterns like `"id": 123` or `"id": "abc"`.
98
+ #
99
+ # @param json_string [String] The (potentially invalid) JSON string.
100
+ # @return [String, nil] The extracted ID as a string if found (numeric or string), otherwise nil.
101
+ def extract_id_from_invalid_json(json_string)
102
+ # Try to find id field with numeric value
103
+ numeric_match = json_string.match(/"id"\s*:\s*(\d+)/)
104
+ return numeric_match[1] if numeric_match
105
+
106
+ # Try to find id field with string value, preserving escaped characters
107
+ string_match = json_string.match(/"id"\s*:\s*"((?:\\.|[^"])*)"/)
108
+ return string_match[1] if string_match
109
+
110
+ nil
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VectorMCP
4
+ # The current version of the VectorMCP gem.
5
+ VERSION = "0.1.0"
6
+ end
data/lib/vector_mcp.rb ADDED
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/vector_mcp.rb
4
+ require "logger"
5
+
6
+ require_relative "vector_mcp/version"
7
+ require_relative "vector_mcp/errors"
8
+ require_relative "vector_mcp/definitions"
9
+ require_relative "vector_mcp/session"
10
+ require_relative "vector_mcp/util"
11
+ require_relative "vector_mcp/handlers/core"
12
+ require_relative "vector_mcp/transport/stdio"
13
+ require_relative "vector_mcp/transport/sse"
14
+ require_relative "vector_mcp/server"
15
+
16
+ # The VectorMCP module provides a full-featured, opinionated Ruby implementation
17
+ # of the **Model Context Protocol (MCP)**. It gives developers everything needed
18
+ # to spin up an MCP-compatible server—including:
19
+ #
20
+ # * **Transport adapters** (synchronous `stdio` or asynchronous HTTP + SSE)
21
+ # * **High-level abstractions** for *tools*, *resources*, and *prompts*
22
+ # * **JSON-RPC 2.0** message handling with sensible defaults and detailed
23
+ # error reporting helpers
24
+ # * A small, dependency-free core (aside from optional async transports) that
25
+ # can be embedded in CLI apps, web servers, or background jobs.
26
+ #
27
+ # At its simplest you can do:
28
+ #
29
+ # ```ruby
30
+ # require "vector_mcp"
31
+ #
32
+ # server = VectorMCP.new(name: "my-mcp-server")
33
+ # server.register_tool(
34
+ # name: "echo",
35
+ # description: "Echo back the supplied text",
36
+ # input_schema: {type: "object", properties: {text: {type: "string"}}}
37
+ # ) { |args| args["text"] }
38
+ #
39
+ # server.run # => starts the stdio transport and begins processing JSON-RPC messages
40
+ # ```
41
+ #
42
+ # For production you could instead pass an `SSE` transport instance to `run` in
43
+ # order to serve multiple concurrent clients over HTTP.
44
+ #
45
+ module VectorMCP
46
+ # @return [Logger] the shared logger instance for the library.
47
+ @logger = Logger.new($stderr, level: Logger::INFO, progname: "VectorMCP")
48
+
49
+ class << self
50
+ # @!attribute [r] logger
51
+ # @return [Logger] the shared logger instance for the library.
52
+ attr_reader :logger
53
+
54
+ # Creates a new {VectorMCP::Server} instance. This is a **thin wrapper** around
55
+ # `VectorMCP::Server.new`; it exists purely for syntactic sugar so you can write
56
+ # `VectorMCP.new` instead of `VectorMCP::Server.new`.
57
+ #
58
+ # Any positional or keyword arguments are forwarded verbatim to the underlying
59
+ # constructor, so refer to {VectorMCP::Server#initialize} for the full list of
60
+ # accepted parameters.
61
+ def new(*args, **kwargs)
62
+ Server.new(*args, **kwargs)
63
+ end
64
+ end
65
+ end
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vector_mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sergio Bayona
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 2.23.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 2.23.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: async-container
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.16'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.16'
40
+ - !ruby/object:Gem::Dependency
41
+ name: async-http
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.61'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.61'
54
+ - !ruby/object:Gem::Dependency
55
+ name: async-io
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.36'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.36'
68
+ - !ruby/object:Gem::Dependency
69
+ name: falcon
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.42'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.42'
82
+ description: Server-side tools for implementing the Model Context Protocol in Ruby
83
+ applications
84
+ email:
85
+ - bayona.sergio@gmail.com
86
+ executables:
87
+ - console
88
+ - setup
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - LICENSE.txt
93
+ - README.md
94
+ - bin/console
95
+ - bin/setup
96
+ - lib/vector_mcp.rb
97
+ - lib/vector_mcp/definitions.rb
98
+ - lib/vector_mcp/errors.rb
99
+ - lib/vector_mcp/handlers/core.rb
100
+ - lib/vector_mcp/server.rb
101
+ - lib/vector_mcp/session.rb
102
+ - lib/vector_mcp/transport/sse.rb
103
+ - lib/vector_mcp/transport/stdio.rb
104
+ - lib/vector_mcp/util.rb
105
+ - lib/vector_mcp/version.rb
106
+ homepage: https://github.com/sergiobayona/vector_mcp
107
+ licenses:
108
+ - MIT
109
+ metadata:
110
+ homepage_uri: https://github.com/sergiobayona/vector_mcp
111
+ source_code_uri: https://github.com/sergiobayona/vector_mcp
112
+ changelog_uri: https://github.com/sergiobayona/vector_mcp/blob/main/CHANGELOG.md
113
+ rubygems_mfa_required: 'true'
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: 3.1.0
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.6.8
129
+ specification_version: 4
130
+ summary: Ruby implementation of the Model Context Protocol (MCP)
131
+ test_files: []