legate 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +345 -0
- data/bin/legate +13 -0
- data/examples/00_quickstart.rb +51 -0
- data/examples/01_simple_agent.rb +105 -0
- data/examples/02_multi_tool_agent.rb +140 -0
- data/examples/03_custom_tool.rb +93 -0
- data/examples/04_agent_instructions.rb +84 -0
- data/examples/05_state_and_sessions.rb +91 -0
- data/examples/06_callbacks.rb +186 -0
- data/examples/07_async_jobs.rb +112 -0
- data/examples/08_loop_agent.rb +197 -0
- data/examples/09_sequential_workflow.rb +40 -0
- data/examples/10_parallel_workflow.rb +34 -0
- data/examples/11_agent_delegation.rb +24 -0
- data/examples/12_http_client_tool.rb +156 -0
- data/examples/13_authentication.rb +220 -0
- data/examples/14_mcp_client.rb +154 -0
- data/examples/15_mcp_server.rb +79 -0
- data/examples/16_webhooks.rb +91 -0
- data/examples/README_sequential_agents.md +164 -0
- data/examples/advanced/auth/cookie_auth_tool.rb +146 -0
- data/examples/advanced/auth/custom_auth_flows_example.rb +626 -0
- data/examples/advanced/auth/excon_middleware.rb +317 -0
- data/examples/advanced/auth/excon_middleware_auth.rb +399 -0
- data/examples/advanced/auth/fiber_auth_example.rb +281 -0
- data/examples/advanced/auth/fiber_oidc_example.rb +403 -0
- data/examples/advanced/auth/httpbin_bearer_tool.rb +159 -0
- data/examples/advanced/auth/oauth2_auth.rb +419 -0
- data/examples/advanced/auth/oidc_auth.rb +514 -0
- data/examples/advanced/auth/openweather_api.rb +251 -0
- data/examples/advanced/auth/openweather_tool.rb +153 -0
- data/examples/advanced/auth/query_param_middleware_test.rb +138 -0
- data/examples/advanced/auth/service_account.rb +135 -0
- data/examples/advanced/auth/test_with_httpbin.rb +202 -0
- data/examples/advanced/auth/token_lifecycle_example.rb +428 -0
- data/examples/advanced/callback_monitoring.rb +679 -0
- data/examples/advanced/mas/fixed_delegation_example.rb +191 -0
- data/examples/advanced/mas/loop_workflow.rb +28 -0
- data/examples/advanced/mas/mock_planner.rb +77 -0
- data/examples/advanced/mas/proper_delegation_example.rb +276 -0
- data/examples/advanced/mcp/legate_mcp_server_resource_example.rb +182 -0
- data/examples/advanced/mcp/mcp_resource_server_example.rb +309 -0
- data/examples/advanced/mcp/mcp_server_async.rb +76 -0
- data/examples/advanced/mcp/mcp_server_async_tools.rb +122 -0
- data/examples/advanced/mcp/mcp_server_legate_agent.rb +95 -0
- data/examples/advanced/mcp/mcp_server_rack.rb +89 -0
- data/examples/advanced/random_calculator.rb +104 -0
- data/examples/advanced/sleep_agent.rb +153 -0
- data/examples/advanced/webhooks/webhook_e2e_runner.rb +110 -0
- data/examples/advanced/webhooks/webhook_receiver_agent.rb +58 -0
- data/examples/advanced/workflows/task_refinement_loop_agent.rb +278 -0
- data/examples/advanced/workflows/travel_planner_auto_sequential.rb +444 -0
- data/examples/advanced/workflows/travel_planner_parallel.rb +656 -0
- data/examples/advanced/workflows/travel_planner_sequential.rb +512 -0
- data/examples/tools/oauth2_example.rb +136 -0
- data/examples/tools/sleepy_tool.rb +42 -0
- data/lib/legate/activity_log.rb +71 -0
- data/lib/legate/agent.rb +959 -0
- data/lib/legate/agent_code_generator.rb +185 -0
- data/lib/legate/agent_definition.rb +812 -0
- data/lib/legate/agentic/decision.rb +49 -0
- data/lib/legate/agentic/loop.rb +134 -0
- data/lib/legate/agentic.rb +5 -0
- data/lib/legate/agents/loop_agent.rb +248 -0
- data/lib/legate/agents/parallel_agent.rb +163 -0
- data/lib/legate/agents/sequential_agent.rb +190 -0
- data/lib/legate/agents.rb +14 -0
- data/lib/legate/auth/config.rb +148 -0
- data/lib/legate/auth/coordinator.rb +218 -0
- data/lib/legate/auth/coordinators/oauth2_coordinator.rb +99 -0
- data/lib/legate/auth/coordinators/oidc_coordinator.rb +68 -0
- data/lib/legate/auth/coordinators/service_account_coordinator.rb +122 -0
- data/lib/legate/auth/credential.rb +157 -0
- data/lib/legate/auth/encryption.rb +108 -0
- data/lib/legate/auth/error.rb +94 -0
- data/lib/legate/auth/exchanged_credential.rb +180 -0
- data/lib/legate/auth/excon_middleware.rb +285 -0
- data/lib/legate/auth/http_client_utils.rb +364 -0
- data/lib/legate/auth/manager.rb +531 -0
- data/lib/legate/auth/manager_store.rb +394 -0
- data/lib/legate/auth/middleware_factory.rb +290 -0
- data/lib/legate/auth/runner.rb +279 -0
- data/lib/legate/auth/scheme.rb +125 -0
- data/lib/legate/auth/schemes/api_key.rb +212 -0
- data/lib/legate/auth/schemes/google_service_account.rb +108 -0
- data/lib/legate/auth/schemes/http_bearer.rb +98 -0
- data/lib/legate/auth/schemes/oauth2.rb +396 -0
- data/lib/legate/auth/schemes/openid_connect.rb +346 -0
- data/lib/legate/auth/schemes/service_account.rb +388 -0
- data/lib/legate/auth/schemes.rb +40 -0
- data/lib/legate/auth/token_manager.rb +362 -0
- data/lib/legate/auth/token_store.rb +86 -0
- data/lib/legate/auth/tool_context_extension.rb +97 -0
- data/lib/legate/auth/tool_integration.rb +188 -0
- data/lib/legate/auth/url_guard.rb +81 -0
- data/lib/legate/auth.rb +453 -0
- data/lib/legate/callbacks/callback_context.rb +71 -0
- data/lib/legate/cli/agent_commands.rb +950 -0
- data/lib/legate/cli/auth_commands.rb +520 -0
- data/lib/legate/cli/base_command.rb +24 -0
- data/lib/legate/cli/deployment_commands.rb +934 -0
- data/lib/legate/cli/output_helper.rb +108 -0
- data/lib/legate/cli/session_commands.rb +138 -0
- data/lib/legate/cli/skaffold_commands.rb +223 -0
- data/lib/legate/cli/tool_commands.rb +261 -0
- data/lib/legate/cli/web_commands.rb +182 -0
- data/lib/legate/cli.rb +40 -0
- data/lib/legate/configuration/webhooks.rb +113 -0
- data/lib/legate/configuration.rb +39 -0
- data/lib/legate/definition_store.rb +23 -0
- data/lib/legate/errors.rb +118 -0
- data/lib/legate/event.rb +161 -0
- data/lib/legate/gemini_ai_beta_patch.rb +39 -0
- data/lib/legate/generators/agent_generator.rb +412 -0
- data/lib/legate/generators/code_validator.rb +48 -0
- data/lib/legate/generators/legate/install_generator.rb +35 -0
- data/lib/legate/generators/legate/templates/create_legate_tables.rb.tt +36 -0
- data/lib/legate/generators/legate/templates/initializer.rb +18 -0
- data/lib/legate/generators/runtime_tool_loader.rb +76 -0
- data/lib/legate/generators/tool_generator.rb +408 -0
- data/lib/legate/generators.rb +11 -0
- data/lib/legate/global_definition_registry.rb +506 -0
- data/lib/legate/global_tool_manager.rb +135 -0
- data/lib/legate/llm/adapter.rb +69 -0
- data/lib/legate/llm/gemini.rb +172 -0
- data/lib/legate/llm/ollama.rb +80 -0
- data/lib/legate/llm.rb +34 -0
- data/lib/legate/mcp/client.rb +320 -0
- data/lib/legate/mcp/connection/sse.rb +292 -0
- data/lib/legate/mcp/connection/stdio.rb +273 -0
- data/lib/legate/mcp/connection_manager.rb +103 -0
- data/lib/legate/mcp/server/legate_agent_adapter.rb +170 -0
- data/lib/legate/mcp/server/legate_direct_agent_adapter.rb +140 -0
- data/lib/legate/mcp/server/legate_tool_adapter.rb +119 -0
- data/lib/legate/mcp/tool_wrapper.rb +138 -0
- data/lib/legate/mcp/util/schema_converter.rb +134 -0
- data/lib/legate/mcp.rb +23 -0
- data/lib/legate/plan_executor.rb +375 -0
- data/lib/legate/planner.rb +839 -0
- data/lib/legate/rails/railtie.rb +43 -0
- data/lib/legate/rails.rb +9 -0
- data/lib/legate/redaction.rb +32 -0
- data/lib/legate/session.rb +299 -0
- data/lib/legate/session_service/active_record.rb +300 -0
- data/lib/legate/session_service/base.rb +68 -0
- data/lib/legate/session_service/event_broadcast.rb +74 -0
- data/lib/legate/session_service/in_memory.rb +188 -0
- data/lib/legate/tool/metadata_dsl.rb +122 -0
- data/lib/legate/tool.rb +276 -0
- data/lib/legate/tool_code_generator.rb +103 -0
- data/lib/legate/tool_context.rb +350 -0
- data/lib/legate/tool_loader.rb +39 -0
- data/lib/legate/tool_registry.rb +73 -0
- data/lib/legate/tool_result.rb +61 -0
- data/lib/legate/tools/agent_tool.rb +187 -0
- data/lib/legate/tools/base/http_client.rb +319 -0
- data/lib/legate/tools/base/safe_url.rb +56 -0
- data/lib/legate/tools/base_async_job_tool.rb +91 -0
- data/lib/legate/tools/calculator.rb +89 -0
- data/lib/legate/tools/cat_facts.rb +81 -0
- data/lib/legate/tools/check_job_status_tool.rb +48 -0
- data/lib/legate/tools/current_time_tool.rb +64 -0
- data/lib/legate/tools/echo.rb +43 -0
- data/lib/legate/tools/http_request_tool.rb +105 -0
- data/lib/legate/tools/random_number_tool.rb +64 -0
- data/lib/legate/tools/read_webpage_tool.rb +92 -0
- data/lib/legate/tools/sleepy_tool.rb +74 -0
- data/lib/legate/tools/webhook_tool.rb +146 -0
- data/lib/legate/version.rb +5 -0
- data/lib/legate/web/app.rb +984 -0
- data/lib/legate/web/public/css/main.css +4980 -0
- data/lib/legate/web/public/images/favicon-256.png +0 -0
- data/lib/legate/web/public/images/favicon-32.png +0 -0
- data/lib/legate/web/public/images/legate-logo-dark.png +0 -0
- data/lib/legate/web/public/images/legate-logo-light.png +0 -0
- data/lib/legate/web/public/js/legate.js +616 -0
- data/lib/legate/web/public/styles/main.scss +4402 -0
- data/lib/legate/web/routes/agent_authentication_routes.rb +530 -0
- data/lib/legate/web/routes/agent_definition_routes.rb +803 -0
- data/lib/legate/web/routes/agent_generator_routes.rb +80 -0
- data/lib/legate/web/routes/agent_interaction_routes.rb +734 -0
- data/lib/legate/web/routes/agent_runtime_routes.rb +323 -0
- data/lib/legate/web/routes/api_routes.rb +56 -0
- data/lib/legate/web/routes/authentication_routes.rb +1541 -0
- data/lib/legate/web/routes/core_routes.rb +111 -0
- data/lib/legate/web/routes/documentation_routes.rb +220 -0
- data/lib/legate/web/routes/tool_generator_routes.rb +81 -0
- data/lib/legate/web/routes/tools_ui_routes.rb +207 -0
- data/lib/legate/web/sass_compiler.rb +73 -0
- data/lib/legate/web/views/_active_session_info.slim +25 -0
- data/lib/legate/web/views/_activity_list.slim +55 -0
- data/lib/legate/web/views/_agent_card.slim +56 -0
- data/lib/legate/web/views/_agent_generator_modal.slim +382 -0
- data/lib/legate/web/views/_agent_status_controls.slim +71 -0
- data/lib/legate/web/views/_agent_tool_table.slim +74 -0
- data/lib/legate/web/views/_chat_message.slim +95 -0
- data/lib/legate/web/views/_display_agent_configuration.slim +26 -0
- data/lib/legate/web/views/_display_agent_description.slim +11 -0
- data/lib/legate/web/views/_display_agent_fallback.slim +15 -0
- data/lib/legate/web/views/_display_agent_hierarchy.slim +93 -0
- data/lib/legate/web/views/_display_agent_instruction.slim +17 -0
- data/lib/legate/web/views/_display_agent_mcp.slim +13 -0
- data/lib/legate/web/views/_display_agent_model.slim +17 -0
- data/lib/legate/web/views/_display_agent_name.slim +42 -0
- data/lib/legate/web/views/_display_agent_output_key.slim +26 -0
- data/lib/legate/web/views/_display_agent_type.slim +65 -0
- data/lib/legate/web/views/_edit_agent_configuration.slim +74 -0
- data/lib/legate/web/views/_edit_agent_description.slim +16 -0
- data/lib/legate/web/views/_edit_agent_fallback.slim +25 -0
- data/lib/legate/web/views/_edit_agent_hierarchy.slim +98 -0
- data/lib/legate/web/views/_edit_agent_instruction.slim +49 -0
- data/lib/legate/web/views/_edit_agent_mcp.slim +33 -0
- data/lib/legate/web/views/_edit_agent_model.slim +23 -0
- data/lib/legate/web/views/_edit_agent_output_key.slim +36 -0
- data/lib/legate/web/views/_edit_agent_tools.slim +40 -0
- data/lib/legate/web/views/_edit_agent_type.slim +67 -0
- data/lib/legate/web/views/_session_error.slim +4 -0
- data/lib/legate/web/views/_skeleton.slim +69 -0
- data/lib/legate/web/views/_tool_card.slim +9 -0
- data/lib/legate/web/views/_tool_generator_modal.slim +311 -0
- data/lib/legate/web/views/agent.slim +436 -0
- data/lib/legate/web/views/agent_auth.slim +562 -0
- data/lib/legate/web/views/agents.slim +369 -0
- data/lib/legate/web/views/auth.slim +112 -0
- data/lib/legate/web/views/auth_credential_detail.slim +327 -0
- data/lib/legate/web/views/auth_credentials.slim +261 -0
- data/lib/legate/web/views/auth_debug.slim +94 -0
- data/lib/legate/web/views/auth_mapping_detail.slim +151 -0
- data/lib/legate/web/views/auth_mapping_new.slim +123 -0
- data/lib/legate/web/views/auth_mappings.slim +120 -0
- data/lib/legate/web/views/auth_scheme_detail.slim +274 -0
- data/lib/legate/web/views/auth_schemes.slim +259 -0
- data/lib/legate/web/views/auth_test.slim +418 -0
- data/lib/legate/web/views/chat.slim +192 -0
- data/lib/legate/web/views/docs_index.slim +105 -0
- data/lib/legate/web/views/docs_show.slim +105 -0
- data/lib/legate/web/views/error_404.slim +5 -0
- data/lib/legate/web/views/index.slim +148 -0
- data/lib/legate/web/views/layout.slim +144 -0
- data/lib/legate/web/views/tool_detail.slim +87 -0
- data/lib/legate/web/views/tools.slim +50 -0
- data/lib/legate/web/webhook_listener.rb +367 -0
- data/lib/legate/web.rb +9 -0
- data/lib/legate.rb +220 -0
- data/public/docs/advanced/callbacks.md +828 -0
- data/public/docs/advanced/mcp_schema_conversion.md +59 -0
- data/public/docs/authentication/api_reference/config.md +210 -0
- data/public/docs/authentication/api_reference/credential.md +246 -0
- data/public/docs/authentication/api_reference/encryption.md +218 -0
- data/public/docs/authentication/api_reference/exchanged_credential.md +271 -0
- data/public/docs/authentication/api_reference/excon_middleware.md +175 -0
- data/public/docs/authentication/api_reference/index.md +30 -0
- data/public/docs/authentication/api_reference/scheme.md +250 -0
- data/public/docs/authentication/api_reference/schemes/api_key.md +175 -0
- data/public/docs/authentication/api_reference/schemes/google_service_account.md +221 -0
- data/public/docs/authentication/api_reference/schemes/http_bearer.md +169 -0
- data/public/docs/authentication/api_reference/schemes/oauth2.md +343 -0
- data/public/docs/authentication/api_reference/schemes/oidc.md +73 -0
- data/public/docs/authentication/api_reference/schemes/openid_connect.md +311 -0
- data/public/docs/authentication/api_reference/schemes/service_account.md +287 -0
- data/public/docs/authentication/api_reference/token_manager.md +221 -0
- data/public/docs/authentication/api_reference/token_store.md +146 -0
- data/public/docs/authentication/api_reference/tool_context_extension.md +166 -0
- data/public/docs/authentication/guides/api_key.md +190 -0
- data/public/docs/authentication/guides/bearer.md +172 -0
- data/public/docs/authentication/guides/configuration.md +255 -0
- data/public/docs/authentication/guides/custom_flow.md +523 -0
- data/public/docs/authentication/guides/index.md +24 -0
- data/public/docs/authentication/guides/migration.md +435 -0
- data/public/docs/authentication/guides/oauth2.md +252 -0
- data/public/docs/authentication/guides/oidc.md +241 -0
- data/public/docs/authentication/guides/overview.md +155 -0
- data/public/docs/authentication/guides/secure_storage.md +301 -0
- data/public/docs/authentication/guides/service_account.md +228 -0
- data/public/docs/authentication/guides/token_lifecycle.md +295 -0
- data/public/docs/authentication/guides/web_ui_integration.md +504 -0
- data/public/docs/authentication/index.md +58 -0
- data/public/docs/authentication/troubleshooting/credential_storage.md +550 -0
- data/public/docs/authentication/troubleshooting/environment_variables.md +540 -0
- data/public/docs/authentication/troubleshooting/index.md +11 -0
- data/public/docs/authentication/troubleshooting/oauth2_issues.md +220 -0
- data/public/docs/authentication/troubleshooting/oidc_issues.md +412 -0
- data/public/docs/authentication/troubleshooting/token_refresh.md +338 -0
- data/public/docs/cli/legate_cli_usage.md +363 -0
- data/public/docs/core_concepts/legate_agent_lifecycle.md +124 -0
- data/public/docs/core_concepts/legate_architecture_overview.md +110 -0
- data/public/docs/core_concepts/legate_configuration.md +116 -0
- data/public/docs/core_concepts/legate_definition_store.md +102 -0
- data/public/docs/core_concepts/legate_planner.md +94 -0
- data/public/docs/core_concepts/legate_session_service.md +104 -0
- data/public/docs/error_handling/legate_error_handling.md +122 -0
- data/public/docs/examples.md +199 -0
- data/public/docs/getting_started.md +111 -0
- data/public/docs/guides/agentic_agents.md +137 -0
- data/public/docs/guides/ai_code_generators.md +437 -0
- data/public/docs/guides/auto_loading.md +326 -0
- data/public/docs/guides/configuring_agent_webhooks.md +219 -0
- data/public/docs/guides/http_client_usage.md +264 -0
- data/public/docs/guides/llm_providers.md +137 -0
- data/public/docs/guides/mcp_client_integration.md +232 -0
- data/public/docs/guides/mcp_server_exposure.md +206 -0
- data/public/docs/guides/rails_integration.md +128 -0
- data/public/docs/guides/sending_outbound_webhooks.md +227 -0
- data/public/docs/guides/streaming.md +112 -0
- data/public/docs/guides/webhooks.md +288 -0
- data/public/docs/introduction.md +51 -0
- data/public/docs/multi_agent_systems/advanced_features.md +57 -0
- data/public/docs/multi_agent_systems/agent_delegation.md +190 -0
- data/public/docs/multi_agent_systems/agent_hierarchy.md +49 -0
- data/public/docs/multi_agent_systems/state_management.md +47 -0
- data/public/docs/multi_agent_systems/workflow_agents.md +72 -0
- data/public/docs/tools/legate_built_in_tools.md +332 -0
- data/public/docs/tools/legate_tools_and_registry.md +263 -0
- data/public/docs/web_ui/legate_web_ui.md +137 -0
- metadata +823 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# File: lib/legate/mcp/connection/sse.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'json'
|
|
7
|
+
require_relative '../../errors'
|
|
8
|
+
|
|
9
|
+
module Legate
|
|
10
|
+
module Mcp
|
|
11
|
+
module Connection
|
|
12
|
+
# Manages a connection to an MCP server via HTTP/SSE.
|
|
13
|
+
# Uses Server-Sent Events (SSE) for server-to-client notifications
|
|
14
|
+
# and standard HTTP POST for client-to-server requests/responses.
|
|
15
|
+
# Automatically reconnects with exponential backoff on stream drops.
|
|
16
|
+
class Sse
|
|
17
|
+
MAX_RECONNECT_ATTEMPTS = 5
|
|
18
|
+
RECONNECT_BASE_DELAY = 1
|
|
19
|
+
RECONNECT_MAX_DELAY = 30
|
|
20
|
+
|
|
21
|
+
attr_reader :url, :last_error, :notification_queue
|
|
22
|
+
|
|
23
|
+
def initialize(url:)
|
|
24
|
+
base_uri = URI.parse(url)
|
|
25
|
+
base_path = base_uri.path
|
|
26
|
+
base_path += '/' unless base_path.end_with?('/')
|
|
27
|
+
@base_uri = base_uri.dup
|
|
28
|
+
@base_uri.path = base_path
|
|
29
|
+
|
|
30
|
+
@sse_uri = @base_uri + 'sse'
|
|
31
|
+
@message_uri = @base_uri + 'messages'
|
|
32
|
+
@sse_reader_thread = nil
|
|
33
|
+
@connected = false
|
|
34
|
+
@disconnecting = false
|
|
35
|
+
@request_id_counter = 0
|
|
36
|
+
@notification_queue = Queue.new
|
|
37
|
+
@connect_signal = nil
|
|
38
|
+
@last_error = nil
|
|
39
|
+
Legate.logger.info("SSE Connection initialized for URL: #{@base_uri}, SSE: #{@sse_uri}, Msg: #{@message_uri}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def connected?
|
|
43
|
+
@connected && @sse_reader_thread&.alive?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Connects to the SSE endpoint and starts listening for notifications.
|
|
47
|
+
# Initial connection is synchronous — raises on failure.
|
|
48
|
+
# After a successful connection, stream drops trigger automatic reconnection.
|
|
49
|
+
# @raise [ConnectionError] if the initial connection fails.
|
|
50
|
+
def connect
|
|
51
|
+
return true if connected?
|
|
52
|
+
|
|
53
|
+
Legate.logger.info("Connecting to SSE endpoint: #{@sse_uri}...")
|
|
54
|
+
@disconnecting = false
|
|
55
|
+
@last_error = nil
|
|
56
|
+
@notification_queue.clear
|
|
57
|
+
@connect_signal = Queue.new
|
|
58
|
+
|
|
59
|
+
@sse_reader_thread = Thread.new { connection_loop }
|
|
60
|
+
|
|
61
|
+
result = @connect_signal.pop(timeout: 10)
|
|
62
|
+
@connect_signal = nil
|
|
63
|
+
|
|
64
|
+
raise ConnectionError, @last_error || 'Failed to establish SSE connection within timeout' unless result == :connected
|
|
65
|
+
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Disconnects the SSE stream and stops reconnection attempts.
|
|
70
|
+
def disconnect
|
|
71
|
+
return unless @connected || @sse_reader_thread
|
|
72
|
+
|
|
73
|
+
Legate.logger.info('Disconnecting SSE connection...')
|
|
74
|
+
@disconnecting = true
|
|
75
|
+
@connected = false
|
|
76
|
+
|
|
77
|
+
if @sse_reader_thread&.alive?
|
|
78
|
+
@sse_reader_thread.kill
|
|
79
|
+
@sse_reader_thread.join(2)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@sse_reader_thread = nil
|
|
83
|
+
@notification_queue.clear
|
|
84
|
+
Legate.logger.info('SSE connection disconnected.')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Sends a request via HTTP POST and returns the response immediately.
|
|
88
|
+
# @param json_rpc_hash [Hash] The JSON-RPC request.
|
|
89
|
+
# @return [Hash] The parsed JSON-RPC response from the server.
|
|
90
|
+
def send_request(json_rpc_hash)
|
|
91
|
+
request_json = json_rpc_hash.to_json
|
|
92
|
+
Legate.logger.debug("-> [MCP Client POST] #{@message_uri} Body: #{request_json}")
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
http = Net::HTTP.new(@message_uri.hostname, @message_uri.port)
|
|
96
|
+
http.use_ssl = (@message_uri.scheme == 'https')
|
|
97
|
+
http.open_timeout = 5
|
|
98
|
+
http.read_timeout = 15
|
|
99
|
+
|
|
100
|
+
request = Net::HTTP::Post.new(@message_uri.request_uri)
|
|
101
|
+
request['Content-Type'] = 'application/json'
|
|
102
|
+
request['Accept'] = 'application/json'
|
|
103
|
+
request.body = request_json
|
|
104
|
+
|
|
105
|
+
response = http.request(request)
|
|
106
|
+
|
|
107
|
+
unless response.is_a?(Net::HTTPOK)
|
|
108
|
+
msg = "MCP POST request failed: #{response.code} #{response.message}. Body: #{response.body[0..500]}"
|
|
109
|
+
Legate.logger.error(msg)
|
|
110
|
+
@last_error = msg
|
|
111
|
+
begin
|
|
112
|
+
error_details = JSON.parse(response.body, symbolize_names: true)
|
|
113
|
+
if error_details[:error]
|
|
114
|
+
raise RemoteToolError.new(error_details[:error][:message], error_details[:error][:code],
|
|
115
|
+
error_details[:error][:data])
|
|
116
|
+
end
|
|
117
|
+
rescue JSON::ParserError
|
|
118
|
+
# Body is not JSON
|
|
119
|
+
end
|
|
120
|
+
raise ConnectionError, msg
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
begin
|
|
124
|
+
response_hash = JSON.parse(response.body, symbolize_names: true)
|
|
125
|
+
Legate.logger.debug("<- [MCP Client POST Response] #{response_hash.inspect}")
|
|
126
|
+
response_hash
|
|
127
|
+
rescue JSON::ParserError => e
|
|
128
|
+
msg = "Failed to parse MCP JSON response from POST: #{e.message}. Body: #{response.body[0..500]}"
|
|
129
|
+
Legate.logger.error(msg)
|
|
130
|
+
@last_error = msg
|
|
131
|
+
raise ProtocolError, msg
|
|
132
|
+
end
|
|
133
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
|
|
134
|
+
@last_error = "Failed to send POST to #{@message_uri}: #{e.class} - #{e.message}"
|
|
135
|
+
Legate.logger.error(@last_error)
|
|
136
|
+
raise ConnectionError, @last_error
|
|
137
|
+
rescue Legate::Mcp::ProtocolError, Legate::Mcp::RemoteToolError
|
|
138
|
+
raise
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
@last_error = "Unexpected error during POST send_request: #{e.class} - #{e.message}"
|
|
141
|
+
Legate.logger.error("#{@last_error}\n#{e.backtrace.join("\n")}")
|
|
142
|
+
raise ConnectionError, @last_error
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Reads the next notification from the queue.
|
|
147
|
+
# @param timeout [Numeric, nil] Seconds to wait, 0 for non-blocking.
|
|
148
|
+
# @return [Hash, nil] Notification hash or nil if queue is empty/timeout occurs.
|
|
149
|
+
def read_notification(timeout = 0.1)
|
|
150
|
+
return nil unless connected?
|
|
151
|
+
|
|
152
|
+
begin
|
|
153
|
+
return @notification_queue.pop(true) if timeout == 0
|
|
154
|
+
|
|
155
|
+
@notification_queue.pop(timeout: timeout)
|
|
156
|
+
rescue ThreadError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def next_request_id
|
|
162
|
+
@request_id_counter += 1
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def connection_loop
|
|
168
|
+
attempt = 0
|
|
169
|
+
initial = true
|
|
170
|
+
|
|
171
|
+
until @disconnecting
|
|
172
|
+
begin
|
|
173
|
+
attempt += 1
|
|
174
|
+
run_sse_stream
|
|
175
|
+
break if @disconnecting
|
|
176
|
+
|
|
177
|
+
@connected = false
|
|
178
|
+
initial = false
|
|
179
|
+
attempt = 0
|
|
180
|
+
Legate.logger.info('SSE stream ended, will reconnect.')
|
|
181
|
+
# IOError covers EOFError, and Timeout::Error covers Net::Open/ReadTimeout,
|
|
182
|
+
# so the subclasses are omitted here to avoid shadowed (redundant) rescues.
|
|
183
|
+
rescue ConnectionError, IOError, Errno::ECONNREFUSED,
|
|
184
|
+
Errno::ECONNRESET, Errno::EHOSTUNREACH, SocketError,
|
|
185
|
+
Timeout::Error => e
|
|
186
|
+
break if @disconnecting
|
|
187
|
+
|
|
188
|
+
@connected = false
|
|
189
|
+
@last_error = e.message
|
|
190
|
+
|
|
191
|
+
if initial
|
|
192
|
+
signal_connect(:failed)
|
|
193
|
+
return
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if attempt > MAX_RECONNECT_ATTEMPTS
|
|
197
|
+
Legate.logger.error("SSE: Max reconnect attempts (#{MAX_RECONNECT_ATTEMPTS}) reached: #{e.message}")
|
|
198
|
+
break
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
delay = [RECONNECT_BASE_DELAY * (2**(attempt - 1)), RECONNECT_MAX_DELAY].min
|
|
202
|
+
Legate.logger.warn("SSE reconnect #{attempt}/#{MAX_RECONNECT_ATTEMPTS} (#{e.class}). Retrying in #{delay}s...")
|
|
203
|
+
sleep(delay)
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
@connected = false
|
|
206
|
+
@last_error = "Unexpected SSE error: #{e.class} - #{e.message}"
|
|
207
|
+
Legate.logger.error("#{@last_error}\n#{e.backtrace&.first(5)&.join("\n")}")
|
|
208
|
+
signal_connect(:failed) if initial
|
|
209
|
+
break
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
@connected = false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def run_sse_stream
|
|
217
|
+
Net::HTTP.start(@sse_uri.hostname, @sse_uri.port,
|
|
218
|
+
use_ssl: @sse_uri.scheme == 'https',
|
|
219
|
+
open_timeout: 5, read_timeout: 30) do |http|
|
|
220
|
+
request = Net::HTTP::Get.new(@sse_uri.request_uri)
|
|
221
|
+
request['Accept'] = 'text/event-stream'
|
|
222
|
+
request['Cache-Control'] = 'no-cache'
|
|
223
|
+
|
|
224
|
+
http.request(request) do |response|
|
|
225
|
+
unless response.is_a?(Net::HTTPOK) && response['content-type']&.include?('text/event-stream')
|
|
226
|
+
raise ConnectionError,
|
|
227
|
+
"SSE endpoint returned #{response.code}: #{response['content-type']}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
Legate.logger.info("SSE connection established with #{@sse_uri}.")
|
|
231
|
+
@connected = true
|
|
232
|
+
signal_connect(:connected)
|
|
233
|
+
|
|
234
|
+
buffer = +''
|
|
235
|
+
response.read_body do |chunk|
|
|
236
|
+
buffer << chunk
|
|
237
|
+
while (line_end = buffer.index("\n\n"))
|
|
238
|
+
event_data = buffer.slice!(0, line_end + 2)
|
|
239
|
+
process_sse_event(event_data)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
Legate.logger.info('SSE stream ended.')
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
rescue EOFError
|
|
246
|
+
Legate.logger.info('SSE stream closed by server.')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def signal_connect(status)
|
|
250
|
+
@connect_signal&.push(status)
|
|
251
|
+
@connect_signal = nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def process_sse_event(event_string)
|
|
255
|
+
Legate.logger.debug("Processing SSE Event: #{event_string.inspect}")
|
|
256
|
+
data_buffer = +''
|
|
257
|
+
event_type = nil
|
|
258
|
+
|
|
259
|
+
event_string.each_line do |line|
|
|
260
|
+
line.chomp!
|
|
261
|
+
next if line.empty?
|
|
262
|
+
next if line.start_with?(':')
|
|
263
|
+
|
|
264
|
+
field, value = line.split(':', 2)
|
|
265
|
+
value&.strip!
|
|
266
|
+
|
|
267
|
+
case field
|
|
268
|
+
when 'event'
|
|
269
|
+
event_type = value
|
|
270
|
+
when 'data'
|
|
271
|
+
data_buffer << value << "\n"
|
|
272
|
+
when 'retry'
|
|
273
|
+
Legate.logger.debug("Received SSE retry suggestion: #{value}ms")
|
|
274
|
+
else
|
|
275
|
+
Legate.logger.warn("Ignoring unknown SSE field: #{field}")
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
return if data_buffer.empty?
|
|
280
|
+
|
|
281
|
+
begin
|
|
282
|
+
message = JSON.parse(data_buffer, symbolize_names: true)
|
|
283
|
+
Legate.logger.debug("Parsed SSE notification (event: #{event_type || 'message'}): #{message.inspect}")
|
|
284
|
+
@notification_queue << message
|
|
285
|
+
rescue JSON::ParserError => e
|
|
286
|
+
Legate.logger.error("Failed to parse JSON from SSE data: #{e.message}. Data: #{data_buffer.inspect}")
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# File: lib/legate/mcp/connection/stdio.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'json'
|
|
6
|
+
require_relative '../../errors'
|
|
7
|
+
|
|
8
|
+
module Legate
|
|
9
|
+
module Mcp
|
|
10
|
+
module Connection
|
|
11
|
+
# Manages a connection to an MCP server via STDIO.
|
|
12
|
+
class Stdio
|
|
13
|
+
# How long to wait for process startup or initial output
|
|
14
|
+
PROCESS_START_TIMEOUT = 5 # seconds
|
|
15
|
+
# How long to wait when reading a response line
|
|
16
|
+
READ_TIMEOUT = 10 # seconds
|
|
17
|
+
# Max consecutive JSON parse errors before considering connection broken
|
|
18
|
+
PARSE_ERROR_THRESHOLD = 5
|
|
19
|
+
|
|
20
|
+
attr_reader :command, :args, :last_error
|
|
21
|
+
|
|
22
|
+
def initialize(command:, args: [])
|
|
23
|
+
@command = command
|
|
24
|
+
@args = args
|
|
25
|
+
@stdin = nil
|
|
26
|
+
@stdout = nil
|
|
27
|
+
@stderr = nil
|
|
28
|
+
@wait_thr = nil
|
|
29
|
+
@stderr_thread = nil
|
|
30
|
+
@connected = false
|
|
31
|
+
@request_id_counter = 0
|
|
32
|
+
@response_queue = Queue.new # <-- Back to response_queue
|
|
33
|
+
@notification_queue = Queue.new # <-- Back to notification_queue
|
|
34
|
+
@stdout_reader_thread = nil
|
|
35
|
+
@last_error = nil
|
|
36
|
+
@pid = nil
|
|
37
|
+
@consecutive_parse_errors = 0 # Initialize counter
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def connected?
|
|
41
|
+
@connected && @wait_thr&.alive?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Connects to the MCP server process.
|
|
45
|
+
# Launches the command and starts threads to monitor stdout/stderr.
|
|
46
|
+
# @raise [ConnectionError] if the process fails to start or terminates unexpectedly.
|
|
47
|
+
def connect
|
|
48
|
+
return true if connected?
|
|
49
|
+
|
|
50
|
+
Mcp.logger.info("Connecting via STDIO: #{@command} #{@args.join(' ')}")
|
|
51
|
+
@last_error = nil
|
|
52
|
+
stderr_pipe_read, stderr_pipe_write = IO.pipe
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
# Use popen3 to capture stdin, stdout, stderr, and wait_thr
|
|
56
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(@command, *@args, err: stderr_pipe_write)
|
|
57
|
+
@pid = @wait_thr.pid
|
|
58
|
+
stderr_pipe_write.close # Close the write end in the parent
|
|
59
|
+
|
|
60
|
+
Mcp.logger.debug("MCP process started with PID: #{@pid}")
|
|
61
|
+
|
|
62
|
+
# Thread to read stderr and log/store errors
|
|
63
|
+
@stderr_thread = Thread.new do
|
|
64
|
+
stderr_pipe_read.each_line do |line|
|
|
65
|
+
Mcp.logger.error("[MCP Server STDERR] #{line.chomp}")
|
|
66
|
+
@last_error = line.chomp # Store last error line
|
|
67
|
+
end
|
|
68
|
+
rescue IOError => e
|
|
69
|
+
Mcp.logger.debug("Stderr pipe closed: #{e.message}")
|
|
70
|
+
ensure
|
|
71
|
+
stderr_pipe_read.close unless stderr_pipe_read.closed?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Thread to continuously read stdout and parse JSON-RPC messages
|
|
75
|
+
@stdout_reader_thread = Thread.new do
|
|
76
|
+
Mcp.logger.debug("[Stdio Connection #{@pid}] stdout_reader_thread starting...")
|
|
77
|
+
begin
|
|
78
|
+
@stdout.each_line do |line|
|
|
79
|
+
# Handle potential encoding issues from subprocess output
|
|
80
|
+
line.force_encoding('UTF-8')
|
|
81
|
+
line.scrub!('') # Remove invalid bytes
|
|
82
|
+
line.strip! # Remove leading/trailing whitespace
|
|
83
|
+
next if line.empty? # Skip empty lines
|
|
84
|
+
|
|
85
|
+
Legate.logger.debug("<- [MCP Server STDOUT Raw] #{line}")
|
|
86
|
+
|
|
87
|
+
# Attempt to parse only if it looks like JSON
|
|
88
|
+
if line.start_with?('{') || line.start_with?('[')
|
|
89
|
+
begin
|
|
90
|
+
message = JSON.parse(line, symbolize_names: true)
|
|
91
|
+
Legate.logger.debug("[Stdio Connection #{@pid}] Received Parsed JSON:\n#{JSON.pretty_generate(message)}")
|
|
92
|
+
@consecutive_parse_errors = 0 # Reset on successful parse
|
|
93
|
+
|
|
94
|
+
# Route to correct queue based on ID presence/value
|
|
95
|
+
if message.key?(:id) && message[:id].nil? # MCP Notifications might have null id
|
|
96
|
+
@notification_queue << message
|
|
97
|
+
elsif message.key?(:id)
|
|
98
|
+
Legate.logger.debug("[Stdio Connection #{@pid}] Queuing response ID: #{message[:id]}")
|
|
99
|
+
@response_queue << message # Responses have non-null id
|
|
100
|
+
else # Assume notification for now
|
|
101
|
+
@notification_queue << message
|
|
102
|
+
Legate.logger.warn("Received MCP message without explicit id: #{message.inspect}")
|
|
103
|
+
end
|
|
104
|
+
rescue JSON::ParserError => e
|
|
105
|
+
Legate.logger.error("Failed to parse potential MCP JSON from stdout: #{e.message}. Line: #{line}")
|
|
106
|
+
@consecutive_parse_errors += 1
|
|
107
|
+
if @consecutive_parse_errors >= PARSE_ERROR_THRESHOLD # Use >= for clarity
|
|
108
|
+
Legate.logger.fatal("Too many consecutive JSON parse errors (#{PARSE_ERROR_THRESHOLD} reached). Assuming MCP connection broken.")
|
|
109
|
+
@connected = false # Mark connection as broken
|
|
110
|
+
@last_error = 'Too many consecutive JSON parse errors.'
|
|
111
|
+
break # Stop reading from stdout
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
else
|
|
115
|
+
# Log lines that don't look like JSON instead of trying to parse
|
|
116
|
+
Legate.logger.debug("Skipping non-JSON line from MCP STDOUT: #{line}")
|
|
117
|
+
# Do not increment parse error count for these lines
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
Legate.logger.info('MCP Server stdout stream ended.')
|
|
121
|
+
rescue IOError => e
|
|
122
|
+
Legate.logger.info("MCP Server stdout pipe closed: #{e.message}")
|
|
123
|
+
ensure
|
|
124
|
+
@connected = false # Mark as disconnected if stdout closes or loop breaks due to errors
|
|
125
|
+
Mcp.logger.debug("[Stdio Connection #{@pid}] stdout_reader_thread finished.")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@connected = true
|
|
130
|
+
Mcp.logger.info('MCP STDIO connection established.')
|
|
131
|
+
true
|
|
132
|
+
rescue Errno::ENOENT => e
|
|
133
|
+
@last_error = "Command not found: #{@command}"
|
|
134
|
+
Mcp.logger.error("#{@last_error} - #{e.message}")
|
|
135
|
+
raise ConnectionError, @last_error
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
@last_error = "Failed to start MCP process: #{e.message}"
|
|
138
|
+
Mcp.logger.error("#{@last_error}")
|
|
139
|
+
# Clean up if process started partially
|
|
140
|
+
disconnect(force: true)
|
|
141
|
+
raise ConnectionError, @last_error
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Sends a JSON-RPC request object to the server process.
|
|
146
|
+
# @param json_rpc_hash [Hash] The request hash (e.g., {jsonrpc: '2.0', method: '...', params: ..., id: ...})
|
|
147
|
+
# @raise [ConnectionError] if not connected.
|
|
148
|
+
def send_request(json_rpc_hash)
|
|
149
|
+
raise ConnectionError, 'Not connected' unless connected?
|
|
150
|
+
|
|
151
|
+
begin
|
|
152
|
+
request_json = json_rpc_hash.to_json
|
|
153
|
+
Mcp.logger.debug("-> [MCP Client STDIN] #{request_json}")
|
|
154
|
+
@stdin.puts(request_json)
|
|
155
|
+
@stdin.flush # Ensure data is sent immediately
|
|
156
|
+
rescue Errno::EPIPE => e
|
|
157
|
+
@connected = false
|
|
158
|
+
@last_error = "MCP process stdin pipe broke: #{e.message}"
|
|
159
|
+
Mcp.logger.error(@last_error)
|
|
160
|
+
raise ConnectionError, @last_error
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
@connected = false
|
|
163
|
+
@last_error = "Error writing to MCP process stdin: #{e.class} - #{e.message}"
|
|
164
|
+
Mcp.logger.error("#{@last_error}\n#{e.backtrace.join("\n")}")
|
|
165
|
+
raise ConnectionError, @last_error
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Reads the next available response or notification.
|
|
170
|
+
# This is a low-level method; typically use Client methods which match request/response.
|
|
171
|
+
# @param timeout [Numeric, nil] Seconds to wait for a message, nil to wait indefinitely.
|
|
172
|
+
# @return [Hash, nil] The parsed JSON-RPC message, or nil if timeout occurs.
|
|
173
|
+
# @raise [ConnectionError] if not connected or connection lost.
|
|
174
|
+
def read_message(timeout = READ_TIMEOUT)
|
|
175
|
+
raise ConnectionError, 'Not connected' unless connected?
|
|
176
|
+
|
|
177
|
+
# Check both queues, prioritize responses if available
|
|
178
|
+
begin
|
|
179
|
+
@response_queue.pop(true) # non_block = true
|
|
180
|
+
rescue ThreadError
|
|
181
|
+
# Response queue empty, check notifications
|
|
182
|
+
begin
|
|
183
|
+
@notification_queue.pop(true)
|
|
184
|
+
rescue ThreadError
|
|
185
|
+
# Both empty, wait with timeout if specified
|
|
186
|
+
return nil if timeout == 0 # Don't wait if timeout is 0
|
|
187
|
+
|
|
188
|
+
deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
|
|
189
|
+
loop do
|
|
190
|
+
remaining = deadline ? deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) : 1.0
|
|
191
|
+
return nil if remaining <= 0
|
|
192
|
+
|
|
193
|
+
wait = [remaining, 0.5].min
|
|
194
|
+
msg = @response_queue.pop(timeout: wait)
|
|
195
|
+
return msg if msg
|
|
196
|
+
|
|
197
|
+
begin
|
|
198
|
+
return @notification_queue.pop(true)
|
|
199
|
+
rescue ThreadError
|
|
200
|
+
# notification queue empty too
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
raise ConnectionError, 'Connection lost while waiting for message' unless connected?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Disconnects from the server process.
|
|
210
|
+
# Terminates the process and cleans up threads.
|
|
211
|
+
# @param force [Boolean] If true, use SIGKILL if SIGTERM fails.
|
|
212
|
+
# @param timeout [Numeric] Seconds to wait for graceful shutdown.
|
|
213
|
+
def disconnect(force: false, timeout: 5)
|
|
214
|
+
return unless @connected || @wait_thr # Only proceed if we have something to disconnect
|
|
215
|
+
|
|
216
|
+
Mcp.logger.info("Disconnecting from MCP STDIO process (PID: #{@pid})...")
|
|
217
|
+
@connected = false
|
|
218
|
+
|
|
219
|
+
# Close stdin to signal EOF to the process
|
|
220
|
+
@stdin&.close unless @stdin&.closed?
|
|
221
|
+
|
|
222
|
+
# Close stdout/stderr BEFORE killing reader threads so they
|
|
223
|
+
# unblock from IO.read and can exit cleanly
|
|
224
|
+
@stdout&.close unless @stdout&.closed?
|
|
225
|
+
@stderr&.close unless @stderr&.closed?
|
|
226
|
+
|
|
227
|
+
# Join reader threads with timeout, then force-kill if stuck
|
|
228
|
+
[@stdout_reader_thread, @stderr_thread].each do |thr|
|
|
229
|
+
next unless thr&.alive?
|
|
230
|
+
|
|
231
|
+
thr.join(2) || thr.kill
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Terminate the child process
|
|
235
|
+
if @wait_thr&.pid
|
|
236
|
+
pid = @wait_thr.pid
|
|
237
|
+
begin
|
|
238
|
+
Mcp.logger.debug("Sending SIGTERM to PID #{pid}...")
|
|
239
|
+
Process.kill('TERM', pid)
|
|
240
|
+
process_exited = @wait_thr.join(timeout)
|
|
241
|
+
|
|
242
|
+
unless process_exited
|
|
243
|
+
Mcp.logger.warn("MCP process PID #{pid} did not exit after SIGTERM and #{timeout}s timeout.")
|
|
244
|
+
if force
|
|
245
|
+
Mcp.logger.warn("Forcing shutdown with SIGKILL for PID #{pid}.")
|
|
246
|
+
Process.kill('KILL', pid)
|
|
247
|
+
@wait_thr.join(2)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
Mcp.logger.info("MCP process PID #{pid} terminated. Status: #{@wait_thr.value}")
|
|
251
|
+
rescue Errno::ESRCH
|
|
252
|
+
Mcp.logger.info("MCP process PID #{pid} already exited.")
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
Mcp.logger.debug("Caught StandardError during termination: #{e.class}")
|
|
255
|
+
Mcp.logger.error("Error during process termination for PID #{pid}: #{e.message}")
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
@stdin = @stdout = @stderr = @wait_thr = @pid = nil
|
|
260
|
+
@response_queue.clear
|
|
261
|
+
@notification_queue.clear
|
|
262
|
+
Mcp.logger.info('MCP STDIO connection closed.')
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Generates the next unique request ID.
|
|
266
|
+
# @return [Integer]
|
|
267
|
+
def next_request_id
|
|
268
|
+
@request_id_counter += 1
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# File: lib/legate/mcp/connection_manager.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'client'
|
|
5
|
+
require_relative 'tool_wrapper'
|
|
6
|
+
|
|
7
|
+
module Legate
|
|
8
|
+
module Mcp
|
|
9
|
+
# Owns an agent's MCP client connections: connecting to configured servers,
|
|
10
|
+
# discovering and registering their tools into the agent's tool registry, and
|
|
11
|
+
# disconnecting. Extracted from Legate::Agent to keep MCP lifecycle out of the
|
|
12
|
+
# agent's core responsibilities.
|
|
13
|
+
class ConnectionManager
|
|
14
|
+
attr_reader :clients
|
|
15
|
+
|
|
16
|
+
# @param tool_registry [Legate::ToolRegistry] the agent's registry that MCP tools register into
|
|
17
|
+
# @param selected_tool_names [Array<Symbol>] tool names the agent selected (others are skipped)
|
|
18
|
+
# @param agent_name [Symbol, String] for log context
|
|
19
|
+
def initialize(tool_registry:, selected_tool_names:, agent_name:)
|
|
20
|
+
@tool_registry = tool_registry
|
|
21
|
+
@selected_tool_names = selected_tool_names
|
|
22
|
+
@agent_name = agent_name
|
|
23
|
+
@clients = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Connects to each configured MCP server and registers its selected tools.
|
|
27
|
+
# @param servers_config [Array<Hash>, nil] server configs from the definition
|
|
28
|
+
def connect(servers_config)
|
|
29
|
+
return if servers_config.nil? || servers_config.empty?
|
|
30
|
+
|
|
31
|
+
servers_config.each do |config|
|
|
32
|
+
# Transform keys to symbols for the client
|
|
33
|
+
symbolized_config = config.transform_keys(&:to_sym)
|
|
34
|
+
Legate.logger.info("Attempting to connect to MCP server: #{symbolized_config.inspect}")
|
|
35
|
+
begin
|
|
36
|
+
unless %w[stdio sse].include?(symbolized_config[:type])
|
|
37
|
+
Legate.logger.error("Unsupported MCP server type specified: #{symbolized_config[:type].inspect}. Skipping configuration: #{symbolized_config.inspect}")
|
|
38
|
+
next # Skip to the next server config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Explicitly convert known string type values to symbols
|
|
42
|
+
if symbolized_config[:type] == 'stdio'
|
|
43
|
+
symbolized_config[:type] = :stdio
|
|
44
|
+
elsif symbolized_config[:type] == 'sse'
|
|
45
|
+
symbolized_config[:type] = :sse
|
|
46
|
+
end
|
|
47
|
+
# Pass the modified hash
|
|
48
|
+
client = Legate::Mcp::Client.new(symbolized_config)
|
|
49
|
+
client.connect # This performs handshake and gets capabilities
|
|
50
|
+
@clients << client
|
|
51
|
+
discover_and_register_tools(client)
|
|
52
|
+
rescue Legate::Mcp::ConnectionError, Legate::Mcp::ProtocolError => e # More specific MCP errors
|
|
53
|
+
Legate.logger.error("Failed to connect or handshake with MCP server #{config.inspect}: #{e.message}")
|
|
54
|
+
rescue Legate::Mcp::Error => e
|
|
55
|
+
Legate.logger.error("MCP-related error connecting to server #{config.inspect}: #{e.message}")
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
Legate.logger.error("Unexpected error connecting to MCP server #{config.inspect}: #{e.class} - #{e.message}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Disconnects all active MCP clients.
|
|
63
|
+
def disconnect
|
|
64
|
+
return if @clients.nil? || @clients.empty?
|
|
65
|
+
|
|
66
|
+
@clients.each do |client|
|
|
67
|
+
Legate.logger.info('Disconnecting MCP client...')
|
|
68
|
+
client.disconnect
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
Legate.logger.error("Error disconnecting MCP client: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
@clients.clear
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Discovers tools from a connected MCP client and registers the selected
|
|
78
|
+
# ones with the agent's registry.
|
|
79
|
+
# @param client [Legate::Mcp::Client]
|
|
80
|
+
def discover_and_register_tools(client)
|
|
81
|
+
Legate.logger.debug("[Agent E2E Debug] discover_and_register - @tool_registry ID: #{@tool_registry.object_id}")
|
|
82
|
+
begin
|
|
83
|
+
mcp_tool_schemas = client.list_tools
|
|
84
|
+
Legate.logger.debug("[Agent E2E Debug] list_tools returned: #{mcp_tool_schemas.inspect}")
|
|
85
|
+
Legate.logger.info("Discovered #{mcp_tool_schemas.count} tools from MCP server.")
|
|
86
|
+
mcp_tool_schemas.each do |schema|
|
|
87
|
+
tool_name_sym = schema[:name].to_sym
|
|
88
|
+
if @selected_tool_names.include?(tool_name_sym)
|
|
89
|
+
# Pass the agent's specific registry instance
|
|
90
|
+
Legate::Mcp::ToolWrapper.from_mcp_schema(schema, client, @tool_registry)
|
|
91
|
+
else
|
|
92
|
+
Legate.logger.debug("Skipping registration of MCP tool '#{tool_name_sym}' as it was not selected in agent definition.")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
rescue Legate::Mcp::Error => e
|
|
96
|
+
Legate.logger.error("Failed to list tools from MCP server: #{e.message}")
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Legate.logger.error("Unexpected error discovering MCP tools: #{e.class} - #{e.message}")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|