actionmcp 0.1.2 → 0.2.3
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.
- checksums.yaml +4 -4
- data/README.md +133 -30
- data/Rakefile +0 -2
- data/exe/actionmcp_cli +221 -0
- data/lib/action_mcp/capability.rb +52 -0
- data/lib/action_mcp/client.rb +249 -0
- data/lib/action_mcp/configuration.rb +55 -1
- data/lib/action_mcp/content/audio.rb +9 -0
- data/lib/action_mcp/content/image.rb +9 -0
- data/lib/action_mcp/content/resource.rb +13 -0
- data/lib/action_mcp/content/text.rb +8 -1
- data/lib/action_mcp/content.rb +13 -3
- data/lib/action_mcp/engine.rb +34 -0
- data/lib/action_mcp/gem_version.rb +2 -2
- data/lib/action_mcp/integer_array.rb +17 -0
- data/lib/action_mcp/json_rpc/json_rpc_error.rb +22 -1
- data/lib/action_mcp/json_rpc/notification.rb +13 -6
- data/lib/action_mcp/json_rpc/request.rb +26 -2
- data/lib/action_mcp/json_rpc/response.rb +42 -31
- data/lib/action_mcp/json_rpc.rb +1 -7
- data/lib/action_mcp/json_rpc_handler.rb +106 -0
- data/lib/action_mcp/logging.rb +19 -0
- data/lib/action_mcp/prompt.rb +33 -45
- data/lib/action_mcp/prompts_registry.rb +32 -1
- data/lib/action_mcp/registry_base.rb +72 -40
- data/lib/action_mcp/renderable.rb +54 -0
- data/lib/action_mcp/resource.rb +5 -3
- data/lib/action_mcp/server.rb +10 -0
- data/lib/action_mcp/string_array.rb +14 -0
- data/lib/action_mcp/tool.rb +112 -102
- data/lib/action_mcp/tools_registry.rb +28 -3
- data/lib/action_mcp/transport/capabilities.rb +21 -0
- data/lib/action_mcp/transport/messaging.rb +20 -0
- data/lib/action_mcp/transport/prompts.rb +19 -0
- data/lib/action_mcp/transport/sse_client.rb +309 -0
- data/lib/action_mcp/transport/stdio_client.rb +117 -0
- data/lib/action_mcp/transport/tools.rb +20 -0
- data/lib/action_mcp/transport/transport_base.rb +125 -0
- data/lib/action_mcp/transport.rb +1 -238
- data/lib/action_mcp/transport_handler.rb +54 -0
- data/lib/action_mcp/version.rb +4 -5
- data/lib/action_mcp.rb +40 -27
- data/lib/generators/action_mcp/install/install_generator.rb +2 -0
- data/lib/generators/action_mcp/prompt/templates/prompt.rb.erb +3 -1
- data/lib/generators/action_mcp/tool/templates/tool.rb.erb +5 -1
- data/lib/tasks/action_mcp_tasks.rake +28 -5
- metadata +68 -10
- data/exe/action_mcp_stdio +0 -0
- data/lib/action_mcp/json_rpc/base.rb +0 -12
- data/lib/action_mcp/railtie.rb +0 -27
- data/lib/action_mcp/resources_bank.rb +0 -96
data/lib/action_mcp/tool.rb
CHANGED
@@ -1,141 +1,114 @@
|
|
1
|
-
# lib/action_mcp/tool.rb
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
module ActionMCP
|
5
|
-
class
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
4
|
+
# Base class for defining tools.
|
5
|
+
#
|
6
|
+
# Provides a DSL for specifying metadata, properties, and nested collection schemas.
|
7
|
+
# Tools are registered automatically in the ToolsRegistry unless marked as abstract.
|
8
|
+
class Tool < Capability
|
9
|
+
# --------------------------------------------------------------------------
|
10
|
+
# Class Attributes for Tool Metadata and Schema
|
11
|
+
# --------------------------------------------------------------------------
|
12
|
+
# @!attribute _schema_properties
|
13
|
+
# @return [Hash] The schema properties of the tool.
|
14
|
+
# @!attribute _required_properties
|
15
|
+
# @return [Array<String>] The required properties of the tool.
|
11
16
|
class_attribute :_schema_properties, instance_accessor: false, default: {}
|
12
17
|
class_attribute :_required_properties, instance_accessor: false, default: []
|
13
|
-
class_attribute :abstract_tool, instance_accessor: false, default: false
|
14
|
-
|
15
|
-
# Register each non-abstract subclass in ToolsRegistry
|
16
|
-
def self.inherited(subclass)
|
17
|
-
super
|
18
|
-
return if subclass == Tool
|
19
|
-
|
20
|
-
subclass.abstract_tool = false
|
21
|
-
return if subclass.name == "ApplicationTool"
|
22
|
-
|
23
|
-
ToolsRegistry.register(subclass.tool_name, subclass)
|
24
|
-
end
|
25
18
|
|
26
|
-
#
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
abstract_tool
|
34
|
-
end
|
35
|
-
|
36
|
-
# ---------------------------------------------------
|
37
|
-
# Tool Name & Description
|
38
|
-
# ---------------------------------------------------
|
19
|
+
# --------------------------------------------------------------------------
|
20
|
+
# Tool Name and Description DSL
|
21
|
+
# --------------------------------------------------------------------------
|
22
|
+
# Sets or retrieves the tool's name.
|
23
|
+
#
|
24
|
+
# @param name [String, nil] Optional. The name to set for the tool.
|
25
|
+
# @return [String] The current tool name.
|
39
26
|
def self.tool_name(name = nil)
|
40
27
|
if name
|
41
|
-
self.
|
28
|
+
self._capability_name = name
|
42
29
|
else
|
43
|
-
|
30
|
+
_capability_name || default_tool_name
|
44
31
|
end
|
45
32
|
end
|
46
33
|
|
34
|
+
# Returns a default tool name based on the class name.
|
35
|
+
#
|
36
|
+
# @return [String] The default tool name.
|
47
37
|
def self.default_tool_name
|
48
|
-
name.demodulize.underscore.
|
38
|
+
name.demodulize.underscore.sub(/_tool$/, "")
|
49
39
|
end
|
50
40
|
|
51
|
-
|
52
|
-
|
53
|
-
self._description = text
|
54
|
-
else
|
55
|
-
_description
|
56
|
-
end
|
41
|
+
class << self
|
42
|
+
alias default_capability_name default_tool_name
|
57
43
|
end
|
58
44
|
|
59
|
-
#
|
45
|
+
# --------------------------------------------------------------------------
|
60
46
|
# Property DSL (Direct Declaration)
|
61
|
-
#
|
47
|
+
# --------------------------------------------------------------------------
|
48
|
+
# Defines a property for the tool.
|
49
|
+
#
|
50
|
+
# This method builds a JSON Schema definition for the property, registers it
|
51
|
+
# in the tool's schema, and creates an ActiveModel attribute for it.
|
52
|
+
#
|
53
|
+
# @param prop_name [Symbol, String] The property name.
|
54
|
+
# @param type [String] The JSON Schema type (default: "string").
|
55
|
+
# @param description [String, nil] Optional description for the property.
|
56
|
+
# @param required [Boolean] Whether the property is required (default: false).
|
57
|
+
# @param default [Object, nil] The default value for the property.
|
58
|
+
# @param opts [Hash] Additional options for the JSON Schema.
|
59
|
+
# @return [void]
|
62
60
|
def self.property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
|
63
|
-
# Build JSON Schema definition
|
61
|
+
# Build the JSON Schema definition.
|
64
62
|
prop_definition = { type: type }
|
65
63
|
prop_definition[:description] = description if description && !description.empty?
|
66
64
|
prop_definition.merge!(opts) if opts.any?
|
67
65
|
|
68
66
|
self._schema_properties = _schema_properties.merge(prop_name.to_s => prop_definition)
|
69
|
-
self._required_properties = _required_properties.dup
|
70
|
-
|
71
|
-
|
72
|
-
# Map our DSL type to an ActiveModel attribute type.
|
73
|
-
am_type = case type.to_s
|
74
|
-
when "number" then :float
|
75
|
-
when "integer" then :integer
|
76
|
-
when "array" then :string
|
77
|
-
else
|
78
|
-
:string
|
67
|
+
self._required_properties = _required_properties.dup.tap do |req|
|
68
|
+
req << prop_name.to_s if required
|
79
69
|
end
|
80
|
-
|
70
|
+
|
71
|
+
# Map the JSON Schema type to an ActiveModel attribute type.
|
72
|
+
attribute prop_name, map_json_type_to_active_model_type(type), default: default
|
73
|
+
validates prop_name, presence: true, if: -> { required }
|
74
|
+
|
75
|
+
return unless %w[number integer].include?(type)
|
76
|
+
|
77
|
+
validates prop_name, numericality: true
|
81
78
|
end
|
82
79
|
|
83
|
-
#
|
80
|
+
# --------------------------------------------------------------------------
|
84
81
|
# Collection DSL
|
85
|
-
#
|
86
|
-
#
|
87
|
-
#
|
88
|
-
# 1. Without a block:
|
89
|
-
# collection :args, type: "string", description: "Command arguments"
|
82
|
+
# --------------------------------------------------------------------------
|
83
|
+
# Defines a collection property for the tool.
|
90
84
|
#
|
91
|
-
#
|
92
|
-
#
|
93
|
-
#
|
94
|
-
#
|
95
|
-
#
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
nested_schema = { type: "object", properties: {}, required: [] }
|
100
|
-
dsl = CollectionDSL.new(nested_schema)
|
101
|
-
dsl.instance_eval(&block)
|
102
|
-
collection_definition = { type: "array", description: description, items: nested_schema }
|
103
|
-
else
|
104
|
-
raise ArgumentError, "Type is required for a collection without a block" if type.nil?
|
85
|
+
# @param prop_name [Symbol, String] The collection property name.
|
86
|
+
# @param type [String] The type for collection items.
|
87
|
+
# @param description [String, nil] Optional description for the collection.
|
88
|
+
# @param required [Boolean] Whether the collection is required (default: false).
|
89
|
+
# @param default [Array, nil] The default value for the collection.
|
90
|
+
# @return [void]
|
91
|
+
def self.collection(prop_name, type:, description: nil, required: false, default: [])
|
92
|
+
raise ArgumentError, "Type is required for a collection" if type.nil?
|
105
93
|
|
106
|
-
|
107
|
-
end
|
94
|
+
collection_definition = { type: "array", description: description, items: { type: type } }
|
108
95
|
|
109
96
|
self._schema_properties = _schema_properties.merge(prop_name.to_s => collection_definition)
|
110
|
-
self._required_properties = _required_properties.dup
|
111
|
-
|
112
|
-
|
113
|
-
# Register the property as an attribute.
|
114
|
-
# (Mapping for a collection can be customized; here we use :string to mimic previous behavior.)
|
115
|
-
attribute prop_name, :string, default: default
|
116
|
-
end
|
117
|
-
|
118
|
-
# DSL for building a nested schema within a collection block.
|
119
|
-
class CollectionDSL
|
120
|
-
attr_reader :schema
|
121
|
-
|
122
|
-
def initialize(schema)
|
123
|
-
@schema = schema
|
97
|
+
self._required_properties = _required_properties.dup.tap do |req|
|
98
|
+
req << prop_name.to_s if required
|
124
99
|
end
|
125
100
|
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
prop_definition.merge!(opts) if opts.any?
|
130
|
-
|
131
|
-
@schema[:properties][prop_name.to_s] = prop_definition
|
132
|
-
@schema[:required] << prop_name.to_s if required
|
133
|
-
end
|
101
|
+
type = map_json_type_to_active_model_type("array_#{type}")
|
102
|
+
attribute prop_name, type, default: default
|
103
|
+
validates prop_name, presence: true, if: -> { required }
|
134
104
|
end
|
135
105
|
|
136
|
-
#
|
137
|
-
#
|
138
|
-
#
|
106
|
+
# --------------------------------------------------------------------------
|
107
|
+
# Tool Definition Serialization
|
108
|
+
# --------------------------------------------------------------------------
|
109
|
+
# Returns a hash representation of the tool definition including its JSON Schema.
|
110
|
+
#
|
111
|
+
# @return [Hash] The tool definition.
|
139
112
|
def self.to_h
|
140
113
|
schema = { type: "object", properties: _schema_properties }
|
141
114
|
schema[:required] = _required_properties if _required_properties.any?
|
@@ -145,5 +118,42 @@ module ActionMCP
|
|
145
118
|
inputSchema: schema
|
146
119
|
}.compact
|
147
120
|
end
|
121
|
+
|
122
|
+
# --------------------------------------------------------------------------
|
123
|
+
# Instance Methods
|
124
|
+
# --------------------------------------------------------------------------
|
125
|
+
# Abstract method to perform the tool's action.
|
126
|
+
#
|
127
|
+
# Subclasses must implement this method.
|
128
|
+
#
|
129
|
+
# @raise [NotImplementedError] Always raised if not implemented in a subclass.
|
130
|
+
# @return [Array<Content>] Array of Content objects is expected as return value
|
131
|
+
def call
|
132
|
+
raise NotImplementedError, "Subclasses must implement the call method"
|
133
|
+
# Default implementation (no-op)
|
134
|
+
# In a real subclass, you might do:
|
135
|
+
# def call
|
136
|
+
# # Perform logic, e.g. analyze code, etc.
|
137
|
+
# # Array of Content objects is expected as return value
|
138
|
+
# end
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
# Maps a JSON Schema type to an ActiveModel attribute type.
|
144
|
+
#
|
145
|
+
# @param type [String] The JSON Schema type.
|
146
|
+
# @return [Symbol] The corresponding ActiveModel attribute type.
|
147
|
+
def self.map_json_type_to_active_model_type(type)
|
148
|
+
case type.to_s
|
149
|
+
when "number" then :float # JSON Schema "number" is a float in Ruby, the spec doesn't have an integer type yet.
|
150
|
+
when "array_number" then :integer_array
|
151
|
+
when "array_integer" then :string_array
|
152
|
+
when "array_string" then :string_array
|
153
|
+
else :string
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
private_class_method :map_json_type_to_active_model_type
|
148
158
|
end
|
149
159
|
end
|
@@ -1,12 +1,37 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
# frozen_string_literal: true
|
4
|
-
|
5
3
|
module ActionMCP
|
4
|
+
# Registry for managing tools.
|
6
5
|
class ToolsRegistry < RegistryBase
|
7
6
|
class << self
|
7
|
+
# @!method tools
|
8
|
+
# Returns all registered tools.
|
9
|
+
# @return [Hash] A hash of registered tools.
|
8
10
|
alias tools items
|
9
|
-
|
11
|
+
|
12
|
+
# Calls a tool with the given name and arguments.
|
13
|
+
#
|
14
|
+
# @param tool_name [String] The name of the tool to call.
|
15
|
+
# @param arguments [Hash] The arguments to pass to the tool.
|
16
|
+
# @param _metadata [Hash] Optional metadata.
|
17
|
+
# @return [Hash] A hash containing the tool's response.
|
18
|
+
def tool_call(tool_name, arguments, _metadata = {})
|
19
|
+
tool = find(tool_name)
|
20
|
+
tool = tool.new(arguments)
|
21
|
+
tool.validate
|
22
|
+
if tool.valid?
|
23
|
+
{ content: [ tool.call ] }
|
24
|
+
else
|
25
|
+
{
|
26
|
+
content: tool.errors.full_messages.map { |msg| Content::Text.new(msg) },
|
27
|
+
isError: true
|
28
|
+
}
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def item_klass
|
33
|
+
Tool
|
34
|
+
end
|
10
35
|
end
|
11
36
|
end
|
12
37
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module ActionMCP
|
2
|
+
module Transport
|
3
|
+
module Capabilities
|
4
|
+
def send_capabilities(request_id, params = {})
|
5
|
+
@protocol_version = params["protocolVersion"]
|
6
|
+
@client_info = params["clientInfo"]
|
7
|
+
@client_capabilities = params["capabilities"]
|
8
|
+
capabilities = ActionMCP.configuration.capabilities
|
9
|
+
|
10
|
+
payload = {
|
11
|
+
protocolVersion: PROTOCOL_VERSION,
|
12
|
+
serverInfo: {
|
13
|
+
name: ActionMCP.configuration.name,
|
14
|
+
version: ActionMCP.configuration.version
|
15
|
+
}
|
16
|
+
}.merge(capabilities)
|
17
|
+
send_jsonrpc_response(request_id, result: payload)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ActionMCP
|
2
|
+
module Transport
|
3
|
+
module Messaging
|
4
|
+
def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
|
5
|
+
request = JsonRpc::Request.new(id: id, method: method, params: params)
|
6
|
+
write_message(request.to_json)
|
7
|
+
end
|
8
|
+
|
9
|
+
def send_jsonrpc_response(request_id, result: nil, error: nil)
|
10
|
+
response = JsonRpc::Response.new(id: request_id, result: result, error: error)
|
11
|
+
write_message(response.to_json)
|
12
|
+
end
|
13
|
+
|
14
|
+
def send_jsonrpc_notification(method, params = nil)
|
15
|
+
notification = JsonRpc::Notification.new(method: method, params: params)
|
16
|
+
write_message(notification.to_json)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ActionMCP
|
2
|
+
module Transport
|
3
|
+
module Prompts
|
4
|
+
def send_prompts_list(request_id)
|
5
|
+
prompts = format_registry_items(PromptsRegistry.non_abstract)
|
6
|
+
send_jsonrpc_response(request_id, result: { prompts: prompts })
|
7
|
+
end
|
8
|
+
|
9
|
+
def send_prompts_get(request_id, prompt_name, params)
|
10
|
+
send_jsonrpc_response(request_id, result: PromptsRegistry.prompt_call(prompt_name.to_s, params))
|
11
|
+
rescue RegistryBase::NotFound
|
12
|
+
send_jsonrpc_response(request_id, error: JsonRpc::JsonRpcError.new(
|
13
|
+
:method_not_found,
|
14
|
+
message: "Prompt not found: #{prompt_name}"
|
15
|
+
).as_json)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,309 @@
|
|
1
|
+
require "faraday"
|
2
|
+
require "uri"
|
3
|
+
|
4
|
+
module ActionMCP
|
5
|
+
module Transport
|
6
|
+
class SSEClient < TransportBase
|
7
|
+
TIMEOUT = 10 # Increased from 1 second
|
8
|
+
ENDPOINT_TIMEOUT = 5 # Seconds
|
9
|
+
|
10
|
+
# Define a custom error class for connection issues
|
11
|
+
class ConnectionError < StandardError; end
|
12
|
+
|
13
|
+
def initialize(url, **options)
|
14
|
+
super(**options)
|
15
|
+
setup_connection(url)
|
16
|
+
@buffer = ""
|
17
|
+
@stop_requested = false
|
18
|
+
@endpoint_received = false
|
19
|
+
@endpoint_mutex = Mutex.new
|
20
|
+
@endpoint_condition = ConditionVariable.new
|
21
|
+
|
22
|
+
# Add connection state management
|
23
|
+
@connection_mutex = Mutex.new
|
24
|
+
@connection_condition = ConditionVariable.new
|
25
|
+
@connection_error = nil
|
26
|
+
@connected = false
|
27
|
+
end
|
28
|
+
|
29
|
+
def start(initialize_request_id)
|
30
|
+
log_info("Connecting to #{@base_url}#{@sse_path}...")
|
31
|
+
@stop_requested = false
|
32
|
+
@initialize_request_id = initialize_request_id
|
33
|
+
|
34
|
+
# Reset connection state before starting
|
35
|
+
@connection_mutex.synchronize do
|
36
|
+
@connected = false
|
37
|
+
@connection_error = nil
|
38
|
+
end
|
39
|
+
|
40
|
+
# Start connection thread
|
41
|
+
@sse_thread = Thread.new { listen_sse }
|
42
|
+
|
43
|
+
# Wait for endpoint instead of connection completion
|
44
|
+
wait_for_endpoint
|
45
|
+
end
|
46
|
+
|
47
|
+
def wait_for_endpoint
|
48
|
+
success = false
|
49
|
+
error = nil
|
50
|
+
|
51
|
+
@endpoint_mutex.synchronize do
|
52
|
+
unless @endpoint_received
|
53
|
+
# Wait with timeout for endpoint
|
54
|
+
timeout = @endpoint_condition.wait(@endpoint_mutex, ENDPOINT_TIMEOUT)
|
55
|
+
|
56
|
+
# Handle timeout
|
57
|
+
unless timeout || @endpoint_received
|
58
|
+
error = "Timeout waiting for MCP endpoint (#{ENDPOINT_TIMEOUT} seconds)"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
success = @endpoint_received
|
63
|
+
end
|
64
|
+
|
65
|
+
if error
|
66
|
+
log_error(error)
|
67
|
+
raise ConnectionError.new(error)
|
68
|
+
end
|
69
|
+
|
70
|
+
# If we have the endpoint, consider the connection successful
|
71
|
+
if success
|
72
|
+
@connection_mutex.synchronize do
|
73
|
+
@connected = true
|
74
|
+
@connection_condition.broadcast
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
success
|
79
|
+
end
|
80
|
+
|
81
|
+
def send_message(json_rpc)
|
82
|
+
# Wait for endpoint if not yet received
|
83
|
+
unless endpoint_ready?
|
84
|
+
log_info("Waiting for endpoint before sending message...")
|
85
|
+
wait_for_endpoint
|
86
|
+
end
|
87
|
+
|
88
|
+
validate_post_endpoint
|
89
|
+
log_debug("\e[34m" + "--> #{json_rpc}" + "\e[0m")
|
90
|
+
send_http_request(json_rpc)
|
91
|
+
end
|
92
|
+
|
93
|
+
def stop
|
94
|
+
log_info("Stopping SSE connection...")
|
95
|
+
@stop_requested = true
|
96
|
+
cleanup_sse_thread
|
97
|
+
end
|
98
|
+
|
99
|
+
def ready?
|
100
|
+
endpoint_ready?
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def setup_connection(url)
|
106
|
+
uri = URI.parse(url)
|
107
|
+
@base_url = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
108
|
+
@sse_path = uri.path
|
109
|
+
|
110
|
+
@conn = Faraday.new(url: @base_url) do |f|
|
111
|
+
f.headers["User-Agent"] = user_agent
|
112
|
+
|
113
|
+
f.options.timeout = nil # No read timeout
|
114
|
+
f.options.open_timeout = 10 # Connection timeout
|
115
|
+
|
116
|
+
# Use Net::HTTP adapter explicitly as it works well with streaming
|
117
|
+
f.adapter :net_http do |http|
|
118
|
+
# Configure the adapter directly
|
119
|
+
http.read_timeout = nil # No read timeout at adapter level too
|
120
|
+
http.open_timeout = 10 # Connection timeout
|
121
|
+
end
|
122
|
+
|
123
|
+
# Add logger middleware
|
124
|
+
# f.response :logger, @logger, headers: true, bodies: true
|
125
|
+
end
|
126
|
+
|
127
|
+
@post_url = nil
|
128
|
+
end
|
129
|
+
|
130
|
+
def endpoint_ready?
|
131
|
+
@endpoint_mutex.synchronize { @endpoint_received }
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
# The listen_sse method should NOT mark connection as successful at the end
|
137
|
+
def listen_sse
|
138
|
+
log_info("Starting SSE listener...")
|
139
|
+
|
140
|
+
begin
|
141
|
+
@conn.get(@sse_path) do |req|
|
142
|
+
req.headers["Accept"] = "text/event-stream"
|
143
|
+
req.headers["Cache-Control"] = "no-cache"
|
144
|
+
|
145
|
+
req.options.on_data = Proc.new do |chunk, bytes|
|
146
|
+
handle_sse_data(chunk, bytes)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# This should never be reached during normal operation
|
151
|
+
# as the SSE connection stays open
|
152
|
+
rescue Faraday::ConnectionFailed => e
|
153
|
+
handle_connection_error(format_connection_error(e))
|
154
|
+
rescue => e
|
155
|
+
handle_connection_error("Unexpected error: #{e.message}")
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def format_connection_error(error)
|
160
|
+
if error.message.include?("Connection refused")
|
161
|
+
"Connection refused - server at #{@base_url} is not running or not accepting connections"
|
162
|
+
else
|
163
|
+
"Connection failed: #{error.message}"
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def handle_connection_error(message)
|
168
|
+
log_error("SSE connection failed: #{message}")
|
169
|
+
|
170
|
+
# Set error and notify waiting threads
|
171
|
+
@connection_mutex.synchronize do
|
172
|
+
@connection_error = message
|
173
|
+
@connection_condition.broadcast
|
174
|
+
end
|
175
|
+
|
176
|
+
@on_error&.call(StandardError.new(message))
|
177
|
+
end
|
178
|
+
|
179
|
+
# Send the initialized notification to the server
|
180
|
+
def send_initialized_notification
|
181
|
+
notification = JsonRpc::Notification.new(
|
182
|
+
method: "notifications/initialized"
|
183
|
+
)
|
184
|
+
|
185
|
+
logger.info("Sent initialized notification to server")
|
186
|
+
send_message(notification.to_json)
|
187
|
+
end
|
188
|
+
|
189
|
+
def handle_sse_data(chunk, _overall_bytes)
|
190
|
+
process_chunk(chunk)
|
191
|
+
throw :halt if @stop_requested
|
192
|
+
end
|
193
|
+
|
194
|
+
def process_chunk(chunk)
|
195
|
+
@buffer << chunk
|
196
|
+
# If the buffer does not contain a newline but appears to be a complete JSON object,
|
197
|
+
# flush it as a complete event.
|
198
|
+
if @buffer.strip.start_with?("{") && @buffer.strip.end_with?("}")
|
199
|
+
(@current_event ||= []) << @buffer.strip
|
200
|
+
@buffer = ""
|
201
|
+
return handle_complete_event
|
202
|
+
end
|
203
|
+
process_buffer while @buffer.include?("\n")
|
204
|
+
end
|
205
|
+
|
206
|
+
|
207
|
+
def process_buffer
|
208
|
+
line, _sep, rest = @buffer.partition("\n")
|
209
|
+
@buffer = rest
|
210
|
+
|
211
|
+
if line.strip.empty?
|
212
|
+
handle_complete_event
|
213
|
+
else
|
214
|
+
(@current_event ||= []) << line.strip
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def handle_complete_event
|
219
|
+
return unless @current_event
|
220
|
+
|
221
|
+
handle_event(@current_event)
|
222
|
+
@current_event = nil
|
223
|
+
end
|
224
|
+
|
225
|
+
def handle_event(lines)
|
226
|
+
event_data = parse_event(lines)
|
227
|
+
process_event(event_data)
|
228
|
+
end
|
229
|
+
|
230
|
+
def parse_event(lines)
|
231
|
+
event_data = { type: "message", data: "" }
|
232
|
+
has_data_prefix = false
|
233
|
+
|
234
|
+
lines.each do |line|
|
235
|
+
if line.start_with?("event:")
|
236
|
+
event_data[:type] = line.split(":", 2)[1].strip
|
237
|
+
elsif line.start_with?("data:")
|
238
|
+
has_data_prefix = true
|
239
|
+
event_data[:data] << line.split(":", 2)[1].strip
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
# If no "data:" prefix was found, treat the entire event as data
|
244
|
+
unless has_data_prefix
|
245
|
+
event_data[:data] = lines.join("\n")
|
246
|
+
end
|
247
|
+
event_data
|
248
|
+
end
|
249
|
+
|
250
|
+
def process_event(event_data)
|
251
|
+
case event_data[:type]
|
252
|
+
when "endpoint" then set_post_endpoint(event_data[:data])
|
253
|
+
when "message" then handle_raw_message(event_data[:data])
|
254
|
+
when "ping" then log_debug("Received ping")
|
255
|
+
else log_error("Unknown event type: #{event_data[:type]}")
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
# Modify set_post_endpoint to mark connection as ready
|
260
|
+
def set_post_endpoint(endpoint_path)
|
261
|
+
@post_url = build_post_url(endpoint_path)
|
262
|
+
log_info("Received POST endpoint: #{@post_url}")
|
263
|
+
|
264
|
+
# Signal that we have received the endpoint
|
265
|
+
@endpoint_mutex.synchronize do
|
266
|
+
@endpoint_received = true
|
267
|
+
@endpoint_condition.broadcast
|
268
|
+
end
|
269
|
+
|
270
|
+
# Now that we have the endpoint, send initial capabilities
|
271
|
+
send_initial_capabilities
|
272
|
+
end
|
273
|
+
|
274
|
+
def build_post_url(endpoint_path)
|
275
|
+
URI.join(@base_url, endpoint_path).to_s
|
276
|
+
rescue StandardError
|
277
|
+
"#{@base_url}#{endpoint_path}"
|
278
|
+
end
|
279
|
+
|
280
|
+
def validate_post_endpoint
|
281
|
+
raise "MCP endpoint not set (no 'endpoint' event received)" unless @post_url
|
282
|
+
end
|
283
|
+
|
284
|
+
def send_http_request(json_rpc)
|
285
|
+
response = @conn.post(@post_url,
|
286
|
+
json_rpc,
|
287
|
+
{ "Content-Type" => "application/json" }
|
288
|
+
)
|
289
|
+
handle_http_response(response)
|
290
|
+
end
|
291
|
+
|
292
|
+
def handle_http_response(response)
|
293
|
+
unless response.success?
|
294
|
+
log_error("HTTP POST failed: #{response.status} - #{response.body}")
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def cleanup_sse_thread
|
299
|
+
return unless @sse_thread
|
300
|
+
|
301
|
+
@sse_thread.join(TIMEOUT) || @sse_thread.kill
|
302
|
+
end
|
303
|
+
|
304
|
+
def user_agent
|
305
|
+
"ActionMCP-sse-client"
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|