actionmcp 0.102.0 → 0.103.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d01a1c6af1a49f9992d61bfe0e0bb6aa6ab8429dbdd21e977c27506fd21cb90e
4
- data.tar.gz: 650eac8eb9711cafffee1b5e27d45873f8a28b3a9c07d27ca91d4bada3c72436
3
+ metadata.gz: 0b98e5c438f85bfe710dbe07d1b9cc2a12d7347b4fc200c66a0492f380615599
4
+ data.tar.gz: 921adb81f040f14533248ce6c52b142a85d8445299e19a83501171b344601c11
5
5
  SHA512:
6
- metadata.gz: 750107898a375f73d7055cb1b346971e5f1bdfe5ca82d8839a280a67c03a396970e9e7919dac5146e456cc0a49ffbe6013bfac0ec8ded7da515bd3691147a18d
7
- data.tar.gz: a1701f0a82a4320896731bc33f7db4968524133e5b6f407ac0174fed70165a36d36079e6f6f179d149305bbbbfb4977320f63d7e36afc78bc16d2f012911dac5
6
+ metadata.gz: 9aee10304fc9e926a34805c760545adb565b5a19dd7fe59e445ed938d82b2e92e9e7d8e5762db2a5dfc12a844fdbbfb9b84ed7a15f2879703fe7c83b6eb29ec2
7
+ data.tar.gz: 4b35888139f56c100415e64f495e61efcd43eac2652bf99b1dfbf1ae269fb317d19fad9f0a1ee99b8a19d1e2a36dd3d1818d4ff76a67456f7e9c7edc21457fb7
data/README.md CHANGED
@@ -46,6 +46,14 @@ In short, ActionMCP helps you build an MCP server (the component that exposes ca
46
46
 
47
47
  > **Client connections:** The client part of ActionMCP is meant to connect to remote MCP servers only. Connecting to local processes (such as via STDIO) is not supported.
48
48
 
49
+ ## Requirements
50
+
51
+ - **Ruby**: 3.4.8+ or 4.0.0+
52
+ - **Rails**: 8.1.1+
53
+ - **Database**: PostgreSQL, MySQL, or SQLite3
54
+
55
+ ActionMCP is tested against Ruby 3.4.8 and 4.0.0 with Rails 8.1.1+.
56
+
49
57
  ## Installation
50
58
 
51
59
  To start using ActionMCP, add it to your project:
@@ -179,7 +187,7 @@ For tools that perform sensitive operations (file system access, database modifi
179
187
  class FileSystemTool < ApplicationMCPTool
180
188
  tool_name "read_file"
181
189
  description "Read contents of a file"
182
-
190
+
183
191
  # Require explicit consent before execution
184
192
  requires_consent!
185
193
 
@@ -392,7 +400,7 @@ ActionMCP provides comprehensive documentation across multiple specialized guide
392
400
  - **[Installation & Configuration](README.md#installation)** - Initial setup, database migrations, and basic configuration
393
401
  - **[Authentication with Gateway](README.md#authentication-with-gateway)** - User authentication and authorization patterns
394
402
 
395
- ### Component Development
403
+ ### Component Development
396
404
  - **[📋 TOOLS.MD](TOOLS.MD)** - Complete guide to developing MCP tools
397
405
  - Generator usage and best practices
398
406
  - Property definitions, validation, and consent management
@@ -1042,7 +1050,7 @@ class MyTool < ApplicationMCPTool
1042
1050
  report_error("Clear error message for the LLM")
1043
1051
  return
1044
1052
  end
1045
-
1053
+
1046
1054
  # Normal processing
1047
1055
  render(text: "Success message")
1048
1056
  end
@@ -51,64 +51,6 @@ module ActionMCP
51
51
  message || "Expected #{expected} session operations#{type_desc}, got #{actual}"
52
52
  end
53
53
 
54
- # Client session store assertions
55
- def assert_client_session_saved(session_id, message = nil)
56
- assert client_session_store.session_saved?(session_id),
57
- message || "Expected client session #{session_id} to have been saved"
58
- end
59
-
60
- def assert_client_session_not_saved(session_id, message = nil)
61
- assert_not client_session_store.session_saved?(session_id),
62
- message || "Expected client session #{session_id} not to have been saved"
63
- end
64
-
65
- def assert_client_session_loaded(session_id, message = nil)
66
- assert client_session_store.session_loaded?(session_id),
67
- message || "Expected client session #{session_id} to have been loaded"
68
- end
69
-
70
- def assert_client_session_not_loaded(session_id, message = nil)
71
- assert_not client_session_store.session_loaded?(session_id),
72
- message || "Expected client session #{session_id} not to have been loaded"
73
- end
74
-
75
- def assert_client_session_updated(session_id, message = nil)
76
- assert client_session_store.session_updated?(session_id),
77
- message || "Expected client session #{session_id} to have been updated"
78
- end
79
-
80
- def assert_client_session_not_updated(session_id, message = nil)
81
- assert_not client_session_store.session_updated?(session_id),
82
- message || "Expected client session #{session_id} not to have been updated"
83
- end
84
-
85
- def assert_client_session_deleted(session_id, message = nil)
86
- assert client_session_store.session_deleted?(session_id),
87
- message || "Expected client session #{session_id} to have been deleted"
88
- end
89
-
90
- def assert_client_session_not_deleted(session_id, message = nil)
91
- assert_not client_session_store.session_deleted?(session_id),
92
- message || "Expected client session #{session_id} not to have been deleted"
93
- end
94
-
95
- def assert_client_session_operation_count(expected, type = nil, message = nil)
96
- actual = client_session_store.operation_count(type)
97
- type_desc = type ? " of type #{type}" : ""
98
- assert_equal expected, actual,
99
- message || "Expected #{expected} client session operations#{type_desc}, got #{actual}"
100
- end
101
-
102
- def assert_client_session_data_includes(session_id, expected_data, message = nil)
103
- saved_data = client_session_store.last_saved_data(session_id)
104
- assert saved_data, "No saved data found for session #{session_id}"
105
-
106
- expected_data.each do |key, value|
107
- assert_equal value, saved_data[key],
108
- message || "Expected session #{session_id} data to include #{key}: #{value}"
109
- end
110
- end
111
-
112
54
  private
113
55
 
114
56
  def server_session_store
@@ -117,18 +59,6 @@ module ActionMCP
117
59
 
118
60
  store
119
61
  end
120
-
121
- def client_session_store
122
- # This would need to be set by the test or could use a thread-local variable
123
- # For now, we'll assume it's available as an instance variable
124
- store = @client_session_store || Thread.current[:test_client_session_store]
125
- unless store
126
- raise "Client session store not set. Set @client_session_store or Thread.current[:test_client_session_store]"
127
- end
128
- raise "Client session store is not a TestSessionStore" unless store.is_a?(ActionMCP::Client::TestSessionStore)
129
-
130
- store
131
- end
132
62
  end
133
63
  end
134
64
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.102.0"
5
+ VERSION = "0.103.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -29,7 +29,6 @@ end.setup
29
29
 
30
30
  module ActionMCP
31
31
  require_relative "action_mcp/version"
32
- require_relative "action_mcp/client"
33
32
 
34
33
  # Error raised when structured content doesn't match the declared output_schema
35
34
  class StructuredContentValidationError < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.102.0
4
+ version: 0.103.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -173,31 +173,6 @@ files:
173
173
  - lib/action_mcp/base_response.rb
174
174
  - lib/action_mcp/callbacks.rb
175
175
  - lib/action_mcp/capability.rb
176
- - lib/action_mcp/client.rb
177
- - lib/action_mcp/client/active_record_session_store.rb
178
- - lib/action_mcp/client/base.rb
179
- - lib/action_mcp/client/blueprint.rb
180
- - lib/action_mcp/client/catalog.rb
181
- - lib/action_mcp/client/collection.rb
182
- - lib/action_mcp/client/elicitation.rb
183
- - lib/action_mcp/client/json_rpc_handler.rb
184
- - lib/action_mcp/client/logging.rb
185
- - lib/action_mcp/client/messaging.rb
186
- - lib/action_mcp/client/prompt_book.rb
187
- - lib/action_mcp/client/prompts.rb
188
- - lib/action_mcp/client/request_timeouts.rb
189
- - lib/action_mcp/client/resources.rb
190
- - lib/action_mcp/client/roots.rb
191
- - lib/action_mcp/client/server.rb
192
- - lib/action_mcp/client/session_store.rb
193
- - lib/action_mcp/client/session_store_factory.rb
194
- - lib/action_mcp/client/streamable_client.rb
195
- - lib/action_mcp/client/streamable_http_transport.rb
196
- - lib/action_mcp/client/test_session_store.rb
197
- - lib/action_mcp/client/toolbox.rb
198
- - lib/action_mcp/client/tools.rb
199
- - lib/action_mcp/client/transport.rb
200
- - lib/action_mcp/client/volatile_session_store.rb
201
176
  - lib/action_mcp/configuration.rb
202
177
  - lib/action_mcp/console_detector.rb
203
178
  - lib/action_mcp/content.rb
@@ -327,7 +302,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
327
302
  - !ruby/object:Gem::Version
328
303
  version: '0'
329
304
  requirements: []
330
- rubygems_version: 4.0.1
305
+ rubygems_version: 4.0.3
331
306
  specification_version: 4
332
307
  summary: Lightweight Model Context Protocol (MCP) server toolkit for Ruby/Rails
333
308
  test_files: []
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # ActiveRecord-backed session store for production
6
- class ActiveRecordSessionStore
7
- include SessionStore
8
-
9
- def load_session(session_id)
10
- session = ActionMCP::Session.find_by(id: session_id)
11
- return nil unless session
12
-
13
- {
14
- id: session.id,
15
- protocol_version: session.protocol_version,
16
- client_info: session.client_info,
17
- client_capabilities: session.client_capabilities,
18
- server_info: session.server_info,
19
- server_capabilities: session.server_capabilities,
20
- created_at: session.created_at,
21
- updated_at: session.updated_at
22
- }
23
- end
24
-
25
- def save_session(session_id, session_data)
26
- session = ActionMCP::Session.find_or_initialize_by(id: session_id)
27
-
28
- # Only assign attributes that exist in the database
29
- attributes = {}
30
- attributes[:protocol_version] = session_data[:protocol_version] if session_data.key?(:protocol_version)
31
- attributes[:client_info] = session_data[:client_info] if session_data.key?(:client_info)
32
- attributes[:client_capabilities] = session_data[:client_capabilities] if session_data.key?(:client_capabilities)
33
- attributes[:server_info] = session_data[:server_info] if session_data.key?(:server_info)
34
- attributes[:server_capabilities] = session_data[:server_capabilities] if session_data.key?(:server_capabilities)
35
-
36
- # Store any extra data in a jsonb column if available
37
- # For now, we'll skip last_event_id and session_data as they don't exist in the DB
38
-
39
- session.assign_attributes(attributes)
40
- session.save!
41
- session_data
42
- end
43
-
44
- def delete_session(session_id)
45
- ActionMCP::Session.find_by(id: session_id)&.destroy
46
- end
47
-
48
- def session_exists?(session_id)
49
- ActionMCP::Session.exists?(id: session_id)
50
- end
51
-
52
- def cleanup_expired_sessions(older_than: 24.hours.ago)
53
- ActionMCP::Session.where("updated_at < ?", older_than).delete_all
54
- end
55
- end
56
- end
57
- end
@@ -1,225 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "transport"
4
-
5
- module ActionMCP
6
- module Client
7
- # Base client class containing common MCP functionality
8
- class Base
9
- # Include all transport protocol modules
10
- include Messaging
11
- include Tools
12
- include Prompts
13
- include Resources
14
- include Roots
15
- include Elicitation
16
-
17
- attr_reader :logger, :transport,
18
- :connection_error, :server,
19
- :server_capabilities, :session,
20
- :catalog, :blueprint,
21
- :prompt_book, :toolbox
22
-
23
- delegate :connected?, :ready?, to: :transport
24
-
25
- def initialize(transport:, logger: ActionMCP.logger, protocol_version: nil, **options)
26
- @logger = logger
27
- @transport = transport
28
- @session = nil # Session will be created/loaded based on server response
29
- @session_id = options[:session_id] # Optional session ID for resumption
30
- @protocol_version = protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION
31
- @server_capabilities = nil
32
- @connection_error = nil
33
- @initialized = false
34
-
35
- # Resource objects
36
- @catalog = Catalog.new([], self)
37
- # Resource template objects
38
- @blueprint = Blueprint.new([], self)
39
- # Prompt objects
40
- @prompt_book = PromptBook.new([], self)
41
- # Tool objects
42
- @toolbox = Toolbox.new([], self)
43
-
44
- setup_transport_callbacks
45
- end
46
-
47
- # Connect to the MCP server
48
- def connect
49
- return true if connected?
50
-
51
- begin
52
- log_debug("Connecting to MCP server via #{transport.class.name}...")
53
- @connection_error = nil
54
-
55
- success = @transport.connect
56
- unless success
57
- log_error("Failed to establish transport connection")
58
- return false
59
- end
60
-
61
- log_debug("Connected to MCP server")
62
- true
63
- rescue StandardError => e
64
- @connection_error = e.message
65
- log_error("Failed to connect to MCP server: #{e.message}")
66
- false
67
- end
68
- end
69
-
70
- # Disconnect from the MCP server
71
- def disconnect
72
- return true unless connected?
73
-
74
- begin
75
- @transport.disconnect
76
- log_debug("Disconnected from MCP server")
77
- true
78
- rescue StandardError => e
79
- log_error("Error disconnecting from MCP server: #{e.message}")
80
- false
81
- end
82
- end
83
-
84
- # Send a request to the MCP server
85
- def write_message(payload)
86
- unless ready?
87
- log_error("Cannot send request - transport not ready")
88
- return false
89
- end
90
-
91
- begin
92
- # Only write to session if it exists (after initialization)
93
- session&.write(payload)
94
- data = payload.to_json unless payload.is_a?(String)
95
- @transport.send_message(data)
96
- true
97
- rescue StandardError => e
98
- log_error("Failed to send request: #{e.message}")
99
- false
100
- end
101
- end
102
-
103
- def server=(server)
104
- @server = if server.is_a?(Client::Server)
105
- server
106
- else
107
- Client::Server.new(server)
108
- end
109
-
110
- # Only update session if it exists
111
- return unless @session
112
-
113
- @session.server_capabilities = server.capabilities
114
- @session.server_info = server.server_info
115
- @session.save
116
- end
117
-
118
- def initialized?
119
- @initialized && @session&.initialized?
120
- end
121
-
122
- def inspect
123
- session_info = @session ? "session: #{@session.id}" : "session: none"
124
- "#<#{self.class.name} transport: #{transport.class.name}, server: #{server}, client_name: #{client_info[:name]}, client_version: #{client_info[:version]}, capabilities: #{client_capabilities}, connected: #{connected?}, initialized: #{initialized?}, #{session_info}>"
125
- end
126
-
127
- protected
128
-
129
- def setup_transport_callbacks
130
- # Create JSON-RPC handler
131
- @json_rpc_handler = JsonRpcHandler.new(session, self)
132
-
133
- # Set up transport callbacks
134
- @transport.on_message do |message|
135
- handle_raw_message(message)
136
- end
137
-
138
- @transport.on_error do |error|
139
- handle_transport_error(error)
140
- end
141
-
142
- @transport.on_connect do
143
- handle_transport_connect
144
- end
145
-
146
- @transport.on_disconnect do
147
- handle_transport_disconnect
148
- end
149
- end
150
-
151
- def handle_raw_message(raw)
152
- @json_rpc_handler.call(raw)
153
- rescue MultiJson::ParseError => e
154
- log_error("JSON parse error: #{e} (raw: #{raw})")
155
- rescue StandardError => e
156
- log_error("Error handling message: #{e} (raw: #{raw})")
157
- end
158
-
159
- def handle_transport_error(error)
160
- @connection_error = error.message
161
- log_error("Transport error: #{error.message}")
162
- end
163
-
164
- def handle_transport_connect
165
- log_debug("Transport connected")
166
- # Send initial capabilities after connection
167
- send_initial_capabilities
168
- end
169
-
170
- def handle_transport_disconnect
171
- log_debug("Transport disconnected")
172
- end
173
-
174
- def send_initial_capabilities
175
- log_debug("Sending client capabilities")
176
-
177
- # If we have a session_id, we're trying to resume
178
- log_debug("Attempting to resume session: #{@session_id}") if @session_id
179
-
180
- params = {
181
- protocolVersion: @protocol_version,
182
- capabilities: client_capabilities,
183
- clientInfo: client_info
184
- }
185
-
186
- # Include session_id if we're trying to resume
187
- params[:sessionId] = @session_id if @session_id
188
-
189
- # Use a unique request ID (not session ID since we don't have one yet)
190
- request_id = SecureRandom.uuid_v7
191
- send_jsonrpc_request("initialize", params: params, id: request_id)
192
- end
193
-
194
- def client_capabilities
195
- {
196
- # Base client capabilities can be defined here
197
- # TODO
198
- }
199
- end
200
-
201
- def user_agent
202
- "ActionMCP-Client"
203
- end
204
-
205
- def client_info
206
- {
207
- name: user_agent,
208
- version: ActionMCP.gem_version.to_s
209
- }
210
- end
211
-
212
- def log_debug(message)
213
- logger.debug("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
214
- end
215
-
216
- def log_info(message)
217
- logger.info("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
218
- end
219
-
220
- def log_error(message)
221
- logger.error("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
222
- end
223
- end
224
- end
225
- end
@@ -1,163 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module Client
5
- # Blueprints
6
- #
7
- # A collection that manages and provides access to URI templates (blueprints) for Model Context Protocol (MCP)
8
- # resource discovery. These blueprints allow dynamic construction of resource URIs by filling in
9
- # variable placeholders with specific values. The class supports lazy loading of templates when
10
- # initialized with a client.
11
- #
12
- # Example usage:
13
- # # Eager loading
14
- # template_data = client.list_resource_templates # Returns array of URI template definitions
15
- # blueprints = Blueprint.new(template_data)
16
- #
17
- # # Lazy loading
18
- # blueprints = Blueprint.new([], client)
19
- # templates = blueprints.all # Templates are loaded here
20
- #
21
- # # Access a specific blueprint by pattern
22
- # file_blueprint = Blueprint.find_by_pattern("file://{path}")
23
- #
24
- # # Generate a concrete URI from a blueprint with parameters
25
- # uri = Blueprint.construct("file://{path}", { path: "/logs/app.log" })
26
- #
27
- class Blueprint < Collection
28
- # Initialize a new Blueprints collection with URI template definitions
29
- #
30
- # @param templates [Array<Hash>] Array of URI template definition hashes, each containing
31
- # uriTemplate, name, description, and optionally mimeType keys
32
- # @param client [Object, nil] Optional client for lazy loading of templates
33
- def initialize(templates, client)
34
- super(templates, client)
35
- self.templates = @collection_data
36
- @load_method = :list_resource_templates
37
- end
38
-
39
- # Find a blueprint by its URI pattern
40
- #
41
- # @param pattern [String] URI template pattern to find
42
- # @return [Blueprint, nil] The blueprint with the given pattern, or nil if not found
43
- def find_by_pattern(pattern)
44
- all.find { |blueprint| blueprint.pattern == pattern }
45
- end
46
-
47
- # Find blueprints by name
48
- #
49
- # @param name [String] Name of the blueprints to find
50
- # @return [Array<Blueprint>] Blueprints with the given name
51
- def find_by_name(name)
52
- all.select { |blueprint| blueprint.name == name }
53
- end
54
-
55
- # Construct a concrete URI by applying parameters to a blueprint
56
- #
57
- # @param pattern [String] URI template pattern to use
58
- # @param params [Hash] Parameters to substitute into the pattern
59
- # @return [String] The constructed URI with parameters applied
60
- # @raise [KeyError] If a required parameter is missing
61
- def construct(pattern, params)
62
- blueprint = find_by_pattern(pattern)
63
- raise ArgumentError, "Unknown blueprint pattern: #{pattern}" unless blueprint
64
-
65
- blueprint.construct(params)
66
- end
67
-
68
- # Check if the collection contains a blueprint with the given pattern
69
- #
70
- # @param pattern [String] The blueprint pattern to check for
71
- # @return [Boolean] true if a blueprint with the pattern exists
72
- def contains?(pattern)
73
- all.any? { |blueprint| blueprint.pattern == pattern }
74
- end
75
-
76
- # Group blueprints by their base protocol
77
- #
78
- # @return [Hash<String, Array<Blueprint>>] Hash mapping protocols to arrays of blueprints
79
- def group_by_protocol
80
- all.group_by(&:protocol)
81
- end
82
-
83
- # Convert raw template data into ResourceTemplate objects
84
- #
85
- # @param templates [Array<Hash>] Array of template definition hashes
86
- def templates=(templates)
87
- @collection_data = templates.map { |template_data| ResourceTemplate.new(template_data) }
88
- end
89
-
90
- # Internal Blueprint class to represent individual URI templates
91
- class ResourceTemplate
92
- attr_reader :pattern, :name, :description, :mime_type, :annotations
93
-
94
- # Initialize a new ResourceTemplate instance
95
- #
96
- # @param data [Hash] ResourceTemplate definition hash containing uriTemplate, name, description,
97
- # and optionally mimeType, and annotations
98
- def initialize(data)
99
- @pattern = data["uriTemplate"]
100
- @name = data["name"]
101
- @description = data["description"]
102
- @mime_type = data["mimeType"]
103
- @variable_pattern = /{([^}]+)}/
104
- @annotations = data["annotations"] || {}
105
- end
106
-
107
- # Extract variable names from the template pattern
108
- #
109
- # @return [Array<String>] List of variable names in the pattern
110
- def variables
111
- @pattern.scan(@variable_pattern).flatten
112
- end
113
-
114
- # Get the protocol part of the URI template
115
- #
116
- # @return [String] The protocol (scheme) of the URI template
117
- def protocol
118
- @pattern.split("://").first
119
- end
120
-
121
- # Construct a concrete URI by substituting parameters into the template pattern
122
- #
123
- # @param params [Hash] Parameters to substitute into the pattern
124
- # @return [String] The constructed URI with parameters applied
125
- # @raise [KeyError] If a required parameter is missing
126
- def construct(params)
127
- result = @pattern.dup
128
-
129
- variables.each do |var|
130
- raise KeyError, "Missing required parameter: #{var}" unless params.key?(var.to_sym) || params.key?(var)
131
-
132
- value = params[var.to_sym] || params[var]
133
- result.gsub!("{#{var}}", value.to_s)
134
- end
135
-
136
- result
137
- end
138
-
139
- # Check if this template is compatible with a set of parameters
140
- #
141
- # @param params [Hash] Parameters to check
142
- # @return [Boolean] true if all required variables have corresponding parameters
143
- def compatible_with?(params)
144
- symbolized_params = params.transform_keys(&:to_sym)
145
- variables.all? { |var| symbolized_params.key?(var.to_sym) }
146
- end
147
-
148
- # Generate a hash representation of the blueprint
149
- #
150
- # @return [Hash] Hash containing blueprint details
151
- def to_h
152
- {
153
- "uriTemplate" => @pattern,
154
- "name" => @name,
155
- "description" => @description,
156
- "mimeType" => @mime_type,
157
- "annotations" => @annotations
158
- }
159
- end
160
- end
161
- end
162
- end
163
- end