actionmcp 0.80.1 → 0.82.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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Logging
5
+ # Null logger that performs no operations when logging is disabled
6
+ # Provides the same interface as Logger but with zero overhead
7
+ class NullLogger
8
+ # Initialize a null logger (no-op)
9
+ # @param args [Array] Any arguments (ignored)
10
+ def initialize(*args, **kwargs)
11
+ # Intentionally empty - no state needed
12
+ end
13
+
14
+ # Log methods - all no-ops that return nil immediately
15
+ def debug(*args, **kwargs, &block)
16
+ nil
17
+ end
18
+
19
+ def info(*args, **kwargs, &block)
20
+ nil
21
+ end
22
+
23
+ def notice(*args, **kwargs, &block)
24
+ nil
25
+ end
26
+
27
+ def warning(*args, **kwargs, &block)
28
+ nil
29
+ end
30
+ alias_method :warn, :warning
31
+
32
+ def error(*args, **kwargs, &block)
33
+ nil
34
+ end
35
+
36
+ def critical(*args, **kwargs, &block)
37
+ nil
38
+ end
39
+
40
+ def alert(*args, **kwargs, &block)
41
+ nil
42
+ end
43
+
44
+ def emergency(*args, **kwargs, &block)
45
+ nil
46
+ end
47
+
48
+ # Level check methods - all return false (nothing will be logged)
49
+ def debug?
50
+ false
51
+ end
52
+
53
+ def info?
54
+ false
55
+ end
56
+
57
+ def notice?
58
+ false
59
+ end
60
+
61
+ def warning?
62
+ false
63
+ end
64
+ alias_method :warn?, :warning?
65
+
66
+ def error?
67
+ false
68
+ end
69
+
70
+ def critical?
71
+ false
72
+ end
73
+
74
+ def alert?
75
+ false
76
+ end
77
+
78
+ def emergency?
79
+ false
80
+ end
81
+
82
+ # Implement any other methods that might be called to avoid NoMethodError
83
+ def method_missing(method_name, *args, **kwargs, &block)
84
+ # Return nil for any unknown method calls
85
+ nil
86
+ end
87
+
88
+ def respond_to_missing?(method_name, include_private = false)
89
+ # Pretend to respond to any method to avoid issues
90
+ true
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module ActionMCP
6
+ module Logging
7
+ # Thread-safe global state for MCP logging
8
+ class State
9
+ # Initialize with default values
10
+ def initialize
11
+ @enabled = Concurrent::AtomicBoolean.new(false)
12
+ @global_level = Concurrent::AtomicFixnum.new(Level::LEVELS[:warning])
13
+ end
14
+
15
+ # Check if logging is enabled
16
+ # @return [Boolean] true if enabled, false otherwise
17
+ def enabled?
18
+ @enabled.value
19
+ end
20
+
21
+ # Enable logging
22
+ # @return [Boolean] true (new value)
23
+ def enable!
24
+ @enabled.make_true
25
+ end
26
+
27
+ # Disable logging
28
+ # @return [Boolean] false (new value)
29
+ def disable!
30
+ @enabled.make_false
31
+ end
32
+
33
+ # Set enabled state
34
+ # @param value [Boolean] true to enable, false to disable
35
+ # @return [Boolean] the new value
36
+ def enabled=(value)
37
+ if value
38
+ enable!
39
+ else
40
+ disable!
41
+ end
42
+ end
43
+
44
+ # Get current minimum log level as integer
45
+ # @return [Integer] the current level (0-7)
46
+ def level
47
+ @global_level.value
48
+ end
49
+
50
+ # Get current minimum log level as symbol
51
+ # @return [Symbol] the current level symbol
52
+ def level_symbol
53
+ Level.name_for(@global_level.value)
54
+ end
55
+
56
+ # Set minimum log level
57
+ # @param new_level [String, Symbol, Integer] the new level
58
+ # @return [Integer] the new level as integer
59
+ def level=(new_level)
60
+ level_int = Level.coerce(new_level)
61
+ @global_level.value = level_int
62
+ level_int
63
+ end
64
+
65
+ # Check if a message at the given level should be logged
66
+ # @param message_level [String, Symbol, Integer] the message level
67
+ # @return [Boolean] true if should be logged, false otherwise
68
+ def should_log?(message_level)
69
+ return false unless enabled?
70
+
71
+ message_level_int = Level.coerce(message_level)
72
+ message_level_int >= @global_level.value
73
+ end
74
+
75
+ # Reset to initial state (for testing)
76
+ # @return [void]
77
+ def reset!
78
+ disable!
79
+ self.level = :warning
80
+ end
81
+ end
82
+ end
83
+ end
@@ -5,13 +5,89 @@ require "active_support/logger"
5
5
  require "logger"
6
6
 
7
7
  module ActionMCP
8
- # Module for providing logging functionality to ActionMCP transport.
8
+ # Global MCP logging interface
9
9
  module Logging
10
- extend ActiveSupport::Concern
10
+ class << self
11
+ # Get the global logging state
12
+ # @return [ActionMCP::Logging::State] The global state
13
+ def state
14
+ @state ||= State.new
15
+ end
11
16
 
12
- # Included hook to configure the logger.
13
- included do
14
- cattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout))
17
+ # Reset the global state (for testing)
18
+ # @return [void]
19
+ def reset!
20
+ @state = State.new
21
+ end
22
+
23
+ # Check if logging is enabled
24
+ # @return [Boolean] true if enabled
25
+ def enabled?
26
+ ActionMCP.configuration.logging_enabled && state.enabled?
27
+ end
28
+
29
+ # Enable MCP logging
30
+ # @return [Boolean] true
31
+ def enable!
32
+ ActionMCP.configuration.logging_enabled = true
33
+ state.enable!
34
+ end
35
+
36
+ # Disable MCP logging
37
+ # @return [Boolean] false
38
+ def disable!
39
+ state.disable!
40
+ end
41
+
42
+ # Get the current minimum log level
43
+ # @return [Symbol] current level
44
+ def level
45
+ state.level_symbol
46
+ end
47
+
48
+ # Set the minimum log level
49
+ # @param new_level [String, Symbol, Integer] the new level
50
+ # @return [Symbol] the new level as symbol
51
+ def level=(new_level)
52
+ state.level = new_level
53
+ state.level_symbol
54
+ end
55
+ alias_method :set_level, :level=
56
+
57
+ # Create a logger for the given session
58
+ # @param name [String, nil] Optional logger name
59
+ # @param session [ActionMCP::Session] The MCP session
60
+ # @return [ActionMCP::Logging::Logger, ActionMCP::Logging::NullLogger] logger instance
61
+ def logger(name: nil, session:)
62
+ if enabled?
63
+ Logger.new(name: name, session: session, state: state)
64
+ else
65
+ NullLogger.new
66
+ end
67
+ end
68
+
69
+ # Convenience method to get a logger for the current session context
70
+ # @param name [String, nil] Optional logger name
71
+ # @param execution_context [Hash] Context containing session
72
+ # @return [ActionMCP::Logging::Logger, ActionMCP::Logging::NullLogger] logger instance
73
+ def logger_for_context(name: nil, execution_context:)
74
+ session = execution_context[:session]
75
+ return NullLogger.new unless session
76
+
77
+ logger(name: name, session: session)
78
+ end
79
+ end
80
+
81
+ # Initialize logging state based on configuration
82
+ def self.initialize_from_config!
83
+ # Always set the level from configuration
84
+ state.level = ActionMCP.configuration.logging_level
85
+
86
+ if ActionMCP.configuration.logging_enabled
87
+ state.enable!
88
+ else
89
+ state.disable!
90
+ end
15
91
  end
16
92
  end
17
93
  end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema_helpers"
4
+
5
+ module ActionMCP
6
+ # DSL builder for creating output JSON Schema from Ruby-like syntax
7
+ # Unlike SchemaBuilder, this preserves nested structure for validation
8
+ class OutputSchemaBuilder
9
+ include SchemaHelpers
10
+
11
+ attr_reader :properties, :required
12
+
13
+ def initialize
14
+ @properties = {}
15
+ @required = []
16
+ end
17
+
18
+ # Define a property with specified type
19
+ # @param name [Symbol] Property name
20
+ # @param type [String] JSON Schema type
21
+ # @param required [Boolean] Whether the property is required
22
+ # @param description [String] Property description
23
+ # @param options [Hash] Additional JSON Schema options
24
+ def property(name, type: "string", required: false, description: nil, **options)
25
+ schema = { "type" => type }
26
+ schema["description"] = description if description
27
+ schema.merge!(options) if options.any?
28
+
29
+ @properties[name.to_s] = schema
30
+ @required << name.to_s if required
31
+
32
+ name.to_s
33
+ end
34
+
35
+ # Define a string property
36
+ def string(name = nil, required: false, description: nil, format: nil, enum: nil,
37
+ default: nil, min_length: nil, max_length: nil)
38
+ schema = { "type" => "string" }
39
+ schema["description"] = description if description
40
+ schema["format"] = format if format
41
+ schema["enum"] = enum if enum
42
+ schema["default"] = default if default
43
+ schema["minLength"] = min_length if min_length
44
+ schema["maxLength"] = max_length if max_length
45
+
46
+ if name
47
+ @properties[name.to_s] = schema
48
+ @required << name.to_s if required
49
+ name.to_s
50
+ else
51
+ # Return schema for use in array items or other contexts
52
+ schema
53
+ end
54
+ end
55
+
56
+ # Define a number property
57
+ def number(name, required: false, description: nil, minimum: nil,
58
+ maximum: nil, default: nil)
59
+ schema = { "type" => "number" }
60
+ schema["description"] = description if description
61
+ schema["minimum"] = minimum if minimum
62
+ schema["maximum"] = maximum if maximum
63
+ schema["default"] = default if default
64
+
65
+ @properties[name.to_s] = schema
66
+ @required << name.to_s if required
67
+
68
+ name.to_s
69
+ end
70
+
71
+ # Define a boolean property
72
+ def boolean(name, required: false, description: nil, default: nil)
73
+ schema = { "type" => "boolean" }
74
+ schema["description"] = description if description
75
+ schema["default"] = default unless default.nil?
76
+
77
+ @properties[name.to_s] = schema
78
+ @required << name.to_s if required
79
+
80
+ name.to_s
81
+ end
82
+
83
+ # Define an array property
84
+ # @param name [Symbol] Array property name
85
+ # @param description [String] Property description
86
+ # @param min_items [Integer] Minimum number of items
87
+ # @param max_items [Integer] Maximum number of items
88
+ # @param items [Hash] Items schema (if not using block)
89
+ # @param block [Proc] Block defining item schema
90
+ def array(name, description: nil, min_items: nil, max_items: nil, items: nil, &block)
91
+ schema = { "type" => "array" }
92
+ schema["description"] = description if description
93
+ schema["minItems"] = min_items if min_items
94
+ schema["maxItems"] = max_items if max_items
95
+
96
+ if block_given?
97
+ # Create nested builder for items
98
+ item_builder = OutputSchemaBuilder.new
99
+ result = item_builder.instance_eval(&block)
100
+
101
+ # If the block returned a schema directly (e.g., from string()),
102
+ # use that. Otherwise, build an object schema from properties.
103
+ if result.is_a?(Hash) && result["type"]
104
+ schema["items"] = result
105
+ elsif item_builder.properties.empty?
106
+ # Block didn't define properties, assume string items
107
+ schema["items"] = { "type" => "string" }
108
+ else
109
+ # Block defined object properties
110
+ item_schema = {
111
+ "type" => "object",
112
+ "properties" => item_builder.properties
113
+ }
114
+ item_schema["required"] = item_builder.required if item_builder.required.any?
115
+ schema["items"] = item_schema
116
+ end
117
+ elsif items
118
+ schema["items"] = items
119
+ else
120
+ # Default to string items
121
+ schema["items"] = { "type" => "string" }
122
+ end
123
+
124
+ @properties[name.to_s] = schema
125
+
126
+ name.to_s
127
+ end
128
+
129
+ # Define an object property
130
+ # @param name [Symbol] Object property name
131
+ # @param required [Boolean] Whether the object is required
132
+ # @param description [String] Property description
133
+ # @param additional_properties [Boolean, Hash] Whether to allow additional properties
134
+ # @param block [Proc] Block defining object properties
135
+ def object(name, required: false, description: nil, additional_properties: nil, &block)
136
+ raise ArgumentError, "Object definition requires a block" unless block_given?
137
+
138
+ # Create nested builder for object properties
139
+ object_builder = OutputSchemaBuilder.new
140
+ object_builder.instance_eval(&block)
141
+
142
+ schema = {
143
+ "type" => "object",
144
+ "properties" => object_builder.properties
145
+ }
146
+ schema["description"] = description if description
147
+ schema["required"] = object_builder.required if object_builder.required.any?
148
+
149
+ # Add additionalProperties if specified
150
+ add_additional_properties_to_schema(schema, additional_properties)
151
+
152
+ @properties[name.to_s] = schema
153
+ @required << name.to_s if required
154
+
155
+ name.to_s
156
+ end
157
+
158
+ # Set additionalProperties for the root schema
159
+ # @param enabled [Boolean, Hash] true to allow any additional properties,
160
+ # false to disallow them, or a Hash for typed additional properties
161
+ def additional_properties(enabled = nil)
162
+ if enabled.nil?
163
+ @additional_properties
164
+ else
165
+ @additional_properties = enabled
166
+ end
167
+ end
168
+
169
+ # Generate the final JSON Schema
170
+ def to_json_schema
171
+ schema = {
172
+ "type" => "object",
173
+ "properties" => @properties
174
+ }
175
+
176
+ schema["required"] = @required.uniq if @required.any?
177
+
178
+ # Add additionalProperties if configured
179
+ add_additional_properties_to_schema(schema, @additional_properties)
180
+
181
+ schema
182
+ end
183
+ end
184
+ end
@@ -8,7 +8,6 @@ module ActionMCP
8
8
  include ActiveModel::Model
9
9
  include ActiveModel::Validations
10
10
  include ResourceCallbacks
11
- include Logging
12
11
  include UriAmbiguityChecker
13
12
  include CurrentHelpers
14
13
 
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Shared utilities for JSON Schema manipulation
5
+ module SchemaHelpers
6
+ private
7
+
8
+ # Helper method to add additionalProperties to a schema hash
9
+ # @param schema [Hash] The schema hash to modify
10
+ # @param additional_properties_value [Boolean, Hash, nil] The additionalProperties configuration
11
+ # @return [Hash] The modified schema hash
12
+ def add_additional_properties_to_schema(schema, additional_properties_value)
13
+ return schema if additional_properties_value.nil?
14
+
15
+ # Use HashWithIndifferentAccess for checking, but modify original schema
16
+ indifferent_schema = schema.with_indifferent_access
17
+
18
+ # Only add additionalProperties if this is a typed schema
19
+ return schema unless indifferent_schema[:type]
20
+
21
+ additional_props = case additional_properties_value
22
+ when true then {}
23
+ when false then false
24
+ when Hash then additional_properties_value
25
+ end
26
+
27
+ # Add to original schema using its key style (symbol or string)
28
+ if schema.key?(:type) || schema.key?("type")
29
+ key = schema.key?(:type) ? :additionalProperties : "additionalProperties"
30
+ schema[key] = additional_props
31
+ end
32
+
33
+ schema
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ module Handlers
6
+ # Handler for MCP logging/setLevel requests
7
+ module LoggingHandler
8
+ # Handle logging/setLevel request
9
+ # @param id [String] The request ID
10
+ # @param params [Hash] Request parameters containing level
11
+ # @return [Hash] Empty hash on success
12
+ def handle_logging_set_level(id, params)
13
+ # Check if logging is enabled
14
+ unless ActionMCP.configuration.logging_enabled
15
+ transport.send_jsonrpc_error(id, :method_not_found, "Logging not enabled")
16
+ return
17
+ end
18
+
19
+ # Extract and validate level parameter
20
+ level = params[:level] || params["level"]
21
+ unless level
22
+ transport.send_jsonrpc_error(id, :invalid_params, "Missing required parameter: level")
23
+ return
24
+ end
25
+
26
+ begin
27
+ # Validate and set the new level
28
+ ActionMCP::Logging.set_level(level)
29
+
30
+ # Send successful response (empty object per MCP spec)
31
+ transport.send_jsonrpc_response(id, result: {})
32
+ rescue ArgumentError => e
33
+ # Invalid level
34
+ transport.send_jsonrpc_error(id, :invalid_params, "Invalid log level: #{e.message}")
35
+ rescue StandardError => e
36
+ # Internal error
37
+ transport.send_jsonrpc_error(id, :internal_error, "Internal error: #{e.message}")
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -6,6 +6,7 @@ module ActionMCP
6
6
  include Handlers::ResourceHandler
7
7
  include Handlers::ToolHandler
8
8
  include Handlers::PromptHandler
9
+ include Handlers::LoggingHandler
9
10
  include ErrorHandling
10
11
  include ErrorAware
11
12
 
@@ -55,6 +56,8 @@ module ActionMCP
55
56
  process_tools(rpc_method, id, params)
56
57
  when Methods::COMPLETION_COMPLETE
57
58
  process_completion_complete(id, params)
59
+ when Methods::LOGGING_SET_LEVEL
60
+ handle_logging_set_level(id, params)
58
61
  else
59
62
  raise JSON_RPC::JsonRpcError.new(:method_not_found, message: "Method not found: #{rpc_method}")
60
63
  end
@@ -89,12 +89,11 @@ module ActionMCP
89
89
  end
90
90
 
91
91
  if result.is_error
92
- # Convert ToolResponse error to proper JSON-RPC error format
93
- # Pass the error hash directly - the Response class will handle it
94
- error_hash = result.to_h
95
- send_jsonrpc_response(request_id, error: error_hash)
92
+ # Protocol error
93
+ send_jsonrpc_response(request_id, error: result.to_h)
96
94
  else
97
- send_jsonrpc_response(request_id, result: result)
95
+ # Success OR tool execution error - both are valid JSON-RPC responses
96
+ send_jsonrpc_response(request_id, result: result.to_h)
98
97
  end
99
98
  rescue ArgumentError => e
100
99
  # Handle parameter validation errors
@@ -10,7 +10,6 @@ module ActionMCP
10
10
 
11
11
  delegate :initialize!, :initialized?, to: :session
12
12
  delegate :read, :write, to: :session
13
- include Logging
14
13
 
15
14
  include MessagingService
16
15
  include Capabilities