ruby_llm 2.0.0 → 2.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 +4 -4
- data/README.md +65 -8
- data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
- data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
- data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
- data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
- data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
- data/lib/ruby_llm/accounting/usage.rb +86 -49
- data/lib/ruby_llm/active_record/acts_as.rb +3 -0
- data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
- data/lib/ruby_llm/active_record/batch.rb +1 -1
- data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
- data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
- data/lib/ruby_llm/active_record/message_methods.rb +32 -7
- data/lib/ruby_llm/active_record/model.rb +1 -1
- data/lib/ruby_llm/active_record/provider_file.rb +64 -0
- data/lib/ruby_llm/active_record/record.rb +19 -0
- data/lib/ruby_llm/active_record/tool_call.rb +1 -1
- data/lib/ruby_llm/active_record/usage.rb +53 -3
- data/lib/ruby_llm/agent.rb +198 -33
- data/lib/ruby_llm/aliases.json +66 -8
- data/lib/ruby_llm/attachment.rb +54 -10
- data/lib/ruby_llm/batch.rb +26 -3
- data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
- data/lib/ruby_llm/chat/tool_search.rb +26 -0
- data/lib/ruby_llm/chat.rb +488 -82
- data/lib/ruby_llm/choice.rb +38 -0
- data/lib/ruby_llm/configuration.rb +36 -11
- data/lib/ruby_llm/context.rb +45 -32
- data/lib/ruby_llm/cost.rb +42 -1
- data/lib/ruby_llm/embedding.rb +9 -2
- data/lib/ruby_llm/error.rb +34 -2
- data/lib/ruby_llm/evaluation/assertions.rb +40 -0
- data/lib/ruby_llm/evaluation/case.rb +52 -0
- data/lib/ruby_llm/evaluation/dataset.rb +65 -0
- data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
- data/lib/ruby_llm/evaluation/evidence.rb +125 -0
- data/lib/ruby_llm/evaluation/minitest.rb +26 -0
- data/lib/ruby_llm/evaluation/report.rb +103 -0
- data/lib/ruby_llm/evaluation/result.rb +75 -0
- data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
- data/lib/ruby_llm/evaluation/rspec.rb +23 -0
- data/lib/ruby_llm/evaluation/runner.rb +93 -0
- data/lib/ruby_llm/evaluation/trial.rb +101 -0
- data/lib/ruby_llm/evaluation.rb +289 -0
- data/lib/ruby_llm/image.rb +12 -4
- data/lib/ruby_llm/judge/builder.rb +51 -0
- data/lib/ruby_llm/judge/data.rb +43 -0
- data/lib/ruby_llm/judge/question.rb +101 -0
- data/lib/ruby_llm/judge.rb +179 -0
- data/lib/ruby_llm/judgment.rb +125 -0
- data/lib/ruby_llm/mcp/apps.rb +25 -0
- data/lib/ruby_llm/mcp/client.rb +213 -0
- data/lib/ruby_llm/mcp/collection.rb +56 -0
- data/lib/ruby_llm/mcp/content.rb +44 -0
- data/lib/ruby_llm/mcp/error.rb +27 -0
- data/lib/ruby_llm/mcp/http.rb +379 -0
- data/lib/ruby_llm/mcp/input_request.rb +118 -0
- data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
- data/lib/ruby_llm/mcp/listener.rb +168 -0
- data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
- data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
- data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
- data/lib/ruby_llm/mcp/oauth.rb +653 -0
- data/lib/ruby_llm/mcp/param_headers.rb +83 -0
- data/lib/ruby_llm/mcp/prompt.rb +71 -0
- data/lib/ruby_llm/mcp/resource.rb +84 -0
- data/lib/ruby_llm/mcp/resource_template.rb +99 -0
- data/lib/ruby_llm/mcp/result.rb +76 -0
- data/lib/ruby_llm/mcp/session_expired.rb +10 -0
- data/lib/ruby_llm/mcp/stdio.rb +250 -0
- data/lib/ruby_llm/mcp/task.rb +151 -0
- data/lib/ruby_llm/mcp/tool.rb +130 -0
- data/lib/ruby_llm/mcp.rb +1101 -0
- data/lib/ruby_llm/message.rb +41 -6
- data/lib/ruby_llm/model.rb +2 -1
- data/lib/ruby_llm/models/aliases.rb +5 -2
- data/lib/ruby_llm/models/lookup.rb +80 -0
- data/lib/ruby_llm/models/registry.rb +2 -2
- data/lib/ruby_llm/models/schema.rb +1 -1
- data/lib/ruby_llm/models.json +23082 -10158
- data/lib/ruby_llm/models.rb +20 -69
- data/lib/ruby_llm/moderation.rb +7 -2
- data/lib/ruby_llm/ocr.rb +7 -2
- data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
- data/lib/ruby_llm/open_telemetry.rb +82 -0
- data/lib/ruby_llm/probability.rb +30 -0
- data/lib/ruby_llm/progress.rb +44 -0
- data/lib/ruby_llm/prompt/context.rb +55 -0
- data/lib/ruby_llm/prompt.rb +1 -1
- data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
- data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
- data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
- data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
- data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
- data/lib/ruby_llm/protocol/streaming.rb +38 -31
- data/lib/ruby_llm/protocol.rb +192 -40
- data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
- data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
- data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
- data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
- data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
- data/lib/ruby_llm/protocols/anthropic.rb +20 -1
- data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
- data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
- data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
- data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
- data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
- data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
- data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
- data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
- data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
- data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
- data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
- data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
- data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
- data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
- data/lib/ruby_llm/protocols/cohere.rb +1 -0
- data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
- data/lib/ruby_llm/protocols/converse/media.rb +16 -2
- data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
- data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
- data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
- data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
- data/lib/ruby_llm/protocols/converse.rb +3 -5
- data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
- data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
- data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
- data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
- data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
- data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
- data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
- data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
- data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
- data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
- data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
- data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
- data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
- data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
- data/lib/ruby_llm/protocols/gemini.rb +1 -0
- data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
- data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
- data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
- data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
- data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
- data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
- data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
- data/lib/ruby_llm/protocols/interactions.rb +1 -0
- data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
- data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
- data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
- data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
- data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
- data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
- data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
- data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
- data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
- data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
- data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
- data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
- data/lib/ruby_llm/protocols/openai/files.rb +0 -7
- data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
- data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
- data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
- data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
- data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
- data/lib/ruby_llm/protocols/responses/media.rb +11 -7
- data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
- data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
- data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
- data/lib/ruby_llm/protocols/responses.rb +1 -0
- data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
- data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
- data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
- data/lib/ruby_llm/protocols/system_one.rb +27 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
- data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
- data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
- data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
- data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
- data/lib/ruby_llm/provider.rb +87 -15
- data/lib/ruby_llm/providers/anthropic.rb +23 -0
- data/lib/ruby_llm/providers/azure/chat.rb +4 -0
- data/lib/ruby_llm/providers/azure/images.rb +2 -2
- data/lib/ruby_llm/providers/azure/media.rb +8 -3
- data/lib/ruby_llm/providers/azure/videos.rb +7 -2
- data/lib/ruby_llm/providers/azure.rb +24 -4
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
- data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
- data/lib/ruby_llm/providers/bedrock.rb +38 -6
- data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
- data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
- data/lib/ruby_llm/providers/deepseek.rb +4 -0
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
- data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
- data/lib/ruby_llm/providers/hetzner.rb +41 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
- data/lib/ruby_llm/providers/mistral/media.rb +8 -4
- data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
- data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
- data/lib/ruby_llm/providers/ollama/media.rb +6 -2
- data/lib/ruby_llm/providers/ollama/models.rb +2 -0
- data/lib/ruby_llm/providers/ollama.rb +11 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
- data/lib/ruby_llm/providers/openai.rb +14 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
- data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
- data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
- data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
- data/lib/ruby_llm/providers/openrouter.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
- data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
- data/lib/ruby_llm/providers/perplexity.rb +11 -0
- data/lib/ruby_llm/providers/typesafe.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
- data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
- data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/vertexai.rb +27 -28
- data/lib/ruby_llm/providers/xai/chat.rb +6 -0
- data/lib/ruby_llm/providers/xai/images.rb +9 -10
- data/lib/ruby_llm/providers/xai/responses.rb +10 -8
- data/lib/ruby_llm/providers/xai/videos.rb +11 -9
- data/lib/ruby_llm/providers/xai.rb +4 -0
- data/lib/ruby_llm/railtie.rb +11 -0
- data/lib/ruby_llm/request_shape/part.rb +117 -0
- data/lib/ruby_llm/request_shape/problem.rb +63 -0
- data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
- data/lib/ruby_llm/request_shape/turn.rb +54 -0
- data/lib/ruby_llm/request_shape.rb +160 -0
- data/lib/ruby_llm/rerank.rb +9 -2
- data/lib/ruby_llm/research_job.rb +21 -5
- data/lib/ruby_llm/score.rb +42 -0
- data/lib/ruby_llm/search_results.rb +2 -2
- data/lib/ruby_llm/server_tool_call.rb +10 -2
- data/lib/ruby_llm/speech.rb +8 -1
- data/lib/ruby_llm/support/cancellation.rb +39 -0
- data/lib/ruby_llm/support/inspectable.rb +3 -2
- data/lib/ruby_llm/support/instrumentation.rb +38 -1
- data/lib/ruby_llm/support/process_cache.rb +60 -0
- data/lib/ruby_llm/support/progress_reporter.rb +33 -0
- data/lib/ruby_llm/thinking.rb +2 -1
- data/lib/ruby_llm/tokens.rb +57 -14
- data/lib/ruby_llm/tool.rb +50 -3
- data/lib/ruby_llm/tool_call.rb +17 -2
- data/lib/ruby_llm/transcription.rb +10 -3
- data/lib/ruby_llm/transport/connection.rb +127 -44
- data/lib/ruby_llm/transport/error_middleware.rb +90 -24
- data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
- data/lib/ruby_llm/transport/json_response.rb +33 -0
- data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm/video.rb +5 -3
- data/lib/ruby_llm/video_job.rb +48 -5
- data/lib/ruby_llm.rb +106 -5
- data/lib/tasks/ruby_llm/evaluations.rb +43 -0
- data/lib/tasks/ruby_llm.rake +6 -9
- data/skills/rubyllm/SKILL.md +33 -12
- metadata +95 -37
- data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
- data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
- data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
- data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
- data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
- data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
- data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
- data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# Streamable HTTP. Every message is its own POST, answered with a JSON
|
|
6
|
+
# body or with an event stream that carries the request's notifications
|
|
7
|
+
# before its response. Plain HTTP is only allowed on loopback addresses,
|
|
8
|
+
# URLs cannot carry credentials, and redirects are never followed. A cancelled chat closes the stream,
|
|
9
|
+
# which is how 2026-07-28 cancels a request. Servers that predate it may
|
|
10
|
+
# keep a session, which ends with a DELETE when the transport closes.
|
|
11
|
+
#
|
|
12
|
+
# When a stream ends before the response, 2026-07-28 sends the request
|
|
13
|
+
# again with a new ID, while older servers resume the stream from its
|
|
14
|
+
# last event ID after the wait they ask for, a few times at most.
|
|
15
|
+
#
|
|
16
|
+
# A subscription's stream stays open, so it gets a connection of its
|
|
17
|
+
# own: callbacks it runs can make requests of their own, even through
|
|
18
|
+
# adapters that keep one connection per thread. Older servers send
|
|
19
|
+
# changes on the session's event stream instead.
|
|
20
|
+
#
|
|
21
|
+
# A server's own requests, on any stream, are answered right away on
|
|
22
|
+
# another connection: pings with a result, the rest with method not
|
|
23
|
+
# found, so a server never waits on RubyLLM.
|
|
24
|
+
class HTTP # :nodoc:
|
|
25
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
26
|
+
HEADER_SAFE = /\A[\x21-\x7E](?:[\x20-\x7E]*[\x21-\x7E])?\z/
|
|
27
|
+
ENCODED_HEADER = /\A=\?base64\?.*\?=\z/
|
|
28
|
+
CLOSE_TIMEOUT = 5
|
|
29
|
+
RECONNECTS = 3
|
|
30
|
+
RECONNECT_DELAY = 1
|
|
31
|
+
CHECK_INTERVAL = 0.1
|
|
32
|
+
|
|
33
|
+
def self.secure?(url)
|
|
34
|
+
uri = URI(url.to_s)
|
|
35
|
+
return false if uri.userinfo
|
|
36
|
+
|
|
37
|
+
uri.scheme == 'https' || (uri.scheme == 'http' && loopback?(uri))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.loopback?(url)
|
|
41
|
+
LOOPBACK_HOSTS.include?(URI(url.to_s).hostname)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(url, headers: {}, timeout: nil, unauthorized: nil, responded: nil, config: RubyLLM.config)
|
|
45
|
+
@url = URI(url)
|
|
46
|
+
unless self.class.secure?(@url)
|
|
47
|
+
raise ArgumentError, "MCP servers must use HTTPS without credentials in the URL: #{url}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@headers = headers
|
|
51
|
+
@unauthorized = unauthorized
|
|
52
|
+
@responded = responded
|
|
53
|
+
@timeout = timeout || config.request_timeout
|
|
54
|
+
@connect = lambda do
|
|
55
|
+
Transport::Connection.basic(config) do |faraday|
|
|
56
|
+
faraday.options.timeout = timeout if timeout
|
|
57
|
+
faraday.adapter config.faraday_adapter
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
@connection = @connect.call
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def request(message, version:, timeout: nil, headers: {}, &)
|
|
64
|
+
stream = post(message, version:, timeout:, params: headers, &)
|
|
65
|
+
RECONNECTS.times do
|
|
66
|
+
if version == Client::VERSION
|
|
67
|
+
break unless stream.interrupted?
|
|
68
|
+
|
|
69
|
+
stream = post(message.merge(id: SecureRandom.uuid), version:, timeout:, params: headers, &)
|
|
70
|
+
else
|
|
71
|
+
break unless resumable?(stream, timeout || @timeout)
|
|
72
|
+
|
|
73
|
+
stream = resume(stream, version:, timeout:, &)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
stream.answer || raise(Error, "#{@url.host} did not answer #{message[:method]}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def notify(message, version:)
|
|
80
|
+
post(message, version:)
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cancel(notification, version:)
|
|
85
|
+
notify(notification, version:) unless version == Client::VERSION
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def listen(message, version:, &)
|
|
89
|
+
return listen_to_session(version:, &) unless message
|
|
90
|
+
|
|
91
|
+
stream = post(message, version:, connection: streams, &)
|
|
92
|
+
stream.answer || raise(Error, "#{@url.host} closed the subscription")
|
|
93
|
+
rescue Faraday::Error => e
|
|
94
|
+
raise Error, e.message
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def close
|
|
98
|
+
session = @session
|
|
99
|
+
@session = nil
|
|
100
|
+
return unless session
|
|
101
|
+
|
|
102
|
+
response = @connection.delete(@url) do |request|
|
|
103
|
+
request.headers.update({ 'MCP-Protocol-Version' => @version, 'Mcp-Session-Id' => session }.compact)
|
|
104
|
+
request.headers.update(custom_headers('DELETE'))
|
|
105
|
+
request.options.timeout = CLOSE_TIMEOUT
|
|
106
|
+
end
|
|
107
|
+
responded(response.headers)
|
|
108
|
+
rescue Faraday::Error => e
|
|
109
|
+
responded(e.response[:headers]) if e.response
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def post(message, version:, timeout: nil, params: {}, connection: @connection, recovered: [], &on_notification)
|
|
115
|
+
session = @session unless message[:method] == 'initialize'
|
|
116
|
+
stream = Stream.new(message[:id], &receiver(version, on_notification))
|
|
117
|
+
stream.read do
|
|
118
|
+
connection.post(@url) do |request|
|
|
119
|
+
request.headers.update(headers(message, version, session))
|
|
120
|
+
params.each { |name, value| request.headers["Mcp-Param-#{name}"] = header_value(value) }
|
|
121
|
+
request.body = JSON.generate(message)
|
|
122
|
+
stream.attach(request, timeout)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
responded(stream.headers)
|
|
126
|
+
remember(message, version, stream.headers)
|
|
127
|
+
stream
|
|
128
|
+
rescue Faraday::Error => e
|
|
129
|
+
raise unless e.response
|
|
130
|
+
|
|
131
|
+
responded(e.response[:headers])
|
|
132
|
+
return stream if answered?(stream)
|
|
133
|
+
raise SessionExpired, "#{@url.host} ended the session" if expired?(e.response, message, session)
|
|
134
|
+
|
|
135
|
+
recovery = recover(e.response, recovered)
|
|
136
|
+
if recovery
|
|
137
|
+
return post(message, version:, timeout:, params:, connection:, recovered: [*recovered, recovery],
|
|
138
|
+
&on_notification)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
raise failure(e.response, stream)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def resume(stream, version:, timeout:, &on_notification)
|
|
145
|
+
wait(stream.retry_after || RECONNECT_DELAY)
|
|
146
|
+
resumed = Stream.new(stream.id, &receiver(version, on_notification))
|
|
147
|
+
get(resumed, version:, timeout:, last_event_id: stream.last_event_id)
|
|
148
|
+
rescue Faraday::Error
|
|
149
|
+
resumed
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def listen_to_session(version:, &on_notification)
|
|
153
|
+
stream = Stream.new(nil, &receiver(version, on_notification))
|
|
154
|
+
get(stream, version:, connection: streams)
|
|
155
|
+
nil
|
|
156
|
+
rescue Faraday::Error => e
|
|
157
|
+
raise Error, e.message unless e.response
|
|
158
|
+
raise SessionExpired, "#{@url.host} ended the session" if e.response[:status] == 404 && @session
|
|
159
|
+
raise Error.new("#{@url.host} sends no events", code: Client::METHOD_NOT_FOUND) if e.response[:status] == 405
|
|
160
|
+
|
|
161
|
+
raise failure(e.response, stream)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def get(stream, version:, timeout: nil, last_event_id: nil, connection: @connection)
|
|
165
|
+
stream.read do
|
|
166
|
+
connection.get(@url) do |request|
|
|
167
|
+
request.headers.update({ 'Accept' => 'text/event-stream', 'MCP-Protocol-Version' => version,
|
|
168
|
+
'Mcp-Session-Id' => @session, 'Last-Event-ID' => last_event_id }.compact)
|
|
169
|
+
request.headers.update(custom_headers('GET'))
|
|
170
|
+
stream.attach(request, timeout)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
responded(stream.headers)
|
|
174
|
+
stream
|
|
175
|
+
rescue Faraday::Error => e
|
|
176
|
+
responded(e.response[:headers]) if e.response
|
|
177
|
+
raise
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def streams
|
|
181
|
+
@streams ||= @connect.call
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def receiver(version, on_notification)
|
|
185
|
+
->(message) { message.key?('id') ? answer(message, version:) : on_notification&.call(message) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def answer(request, version:)
|
|
189
|
+
post(Client.reply(request), version:, connection: answers)
|
|
190
|
+
rescue RubyLLM::Error, Faraday::Error => e
|
|
191
|
+
RubyLLM.logger.debug { "#{@url.host} did not take the answer to #{request['method']}: #{e.message}" }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def answers
|
|
195
|
+
@answers ||= @connect.call
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def resumable?(stream, limit)
|
|
199
|
+
stream.interrupted? && stream.last_event_id && (limit.nil? || (stream.retry_after || 0) <= limit)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def wait(seconds)
|
|
203
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
204
|
+
loop do
|
|
205
|
+
Support::Cancellation.check
|
|
206
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
207
|
+
break unless remaining.positive?
|
|
208
|
+
|
|
209
|
+
sleep [remaining, CHECK_INTERVAL].min
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def remember(message, version, headers)
|
|
214
|
+
@version = version if version
|
|
215
|
+
@session = headers['mcp-session-id'] if message[:method] == 'initialize'
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def expired?(response, message, session)
|
|
219
|
+
session && message[:id] && response[:status] == 404
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Some servers, such as Google's Drive preview, send a complete
|
|
223
|
+
# JSON-RPC result with an error status. The result is the answer.
|
|
224
|
+
def answered?(stream)
|
|
225
|
+
stream.answer&.key?('result')
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Returns how a rejected request recovered, such as with a new token
|
|
229
|
+
# or a nonce for its proof, unless it already recovered that way.
|
|
230
|
+
def recover(response, recovered)
|
|
231
|
+
status = response[:status]
|
|
232
|
+
return unless @unauthorized && [401, 403].include?(status)
|
|
233
|
+
|
|
234
|
+
recovery = @unauthorized.call(response[:headers] || {}, status, recovered)
|
|
235
|
+
recovery if status == 401
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def responded(headers)
|
|
239
|
+
@responded&.call(headers || {})
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def headers(message, version, session)
|
|
243
|
+
{
|
|
244
|
+
'Content-Type' => 'application/json',
|
|
245
|
+
'Accept' => 'application/json, text/event-stream',
|
|
246
|
+
'MCP-Protocol-Version' => version,
|
|
247
|
+
'Mcp-Session-Id' => session,
|
|
248
|
+
'Mcp-Method' => message[:method],
|
|
249
|
+
'Mcp-Name' => header_value(message.dig(:params, :name) || message.dig(:params, :uri) ||
|
|
250
|
+
message.dig(:params, :taskId))
|
|
251
|
+
}.compact.merge(custom_headers('POST'))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def custom_headers(verb)
|
|
255
|
+
@headers.respond_to?(:call) ? @headers.call(verb) : @headers
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def header_value(value)
|
|
259
|
+
return if value.nil?
|
|
260
|
+
|
|
261
|
+
value = value.to_s
|
|
262
|
+
return value if value.empty? || (value.match?(HEADER_SAFE) && !value.match?(ENCODED_HEADER))
|
|
263
|
+
|
|
264
|
+
"=?base64?#{Base64.strict_encode64(value)}?="
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def failure(response, stream)
|
|
268
|
+
case response[:status]
|
|
269
|
+
when 401 then UnauthorizedError.new("#{@url.host} requires authorization", response: response_for(response))
|
|
270
|
+
when 403 then ForbiddenError.new("#{@url.host} refused the request", response: response_for(response))
|
|
271
|
+
else
|
|
272
|
+
error = stream.replies.first&.fetch('error', nil) || {}
|
|
273
|
+
Error.new(error['message'] || "#{@url.host} answered HTTP #{response[:status]}",
|
|
274
|
+
code: error['code'], data: error['data'], response: response_for(response))
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def response_for(response)
|
|
279
|
+
Faraday::Response.new(status: response[:status], response_headers: response[:headers],
|
|
280
|
+
body: response[:body])
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Collects the answers in one response and yields the messages the
|
|
284
|
+
# server sends of its own, notifications and requests, as they arrive.
|
|
285
|
+
# The body is either a single JSON value or a server-sent event
|
|
286
|
+
# stream; the first character tells them apart. An event stream stops
|
|
287
|
+
# being read once it carries the answer to request +id+, since servers
|
|
288
|
+
# may keep it open, and one that breaks midway counts as ended. Only
|
|
289
|
+
# answers are kept, so a stream that stays open does not grow.
|
|
290
|
+
# Faraday 2 passes the response headers with each chunk; Faraday 1
|
|
291
|
+
# has them only once the response ends.
|
|
292
|
+
class Stream # :nodoc:
|
|
293
|
+
attr_reader :id, :headers
|
|
294
|
+
|
|
295
|
+
def initialize(id, &on_message)
|
|
296
|
+
@id = id
|
|
297
|
+
@on_message = on_message
|
|
298
|
+
@parser = Transport::EventStreamParser.new
|
|
299
|
+
@body = +''
|
|
300
|
+
@replies = []
|
|
301
|
+
@headers = {}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def attach(request, timeout)
|
|
305
|
+
request.options.timeout = timeout if timeout
|
|
306
|
+
request.options.on_data = method(:feed).to_proc
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def read(&)
|
|
310
|
+
response = catch(self, &)
|
|
311
|
+
@headers = response.headers if @headers.empty? && response.respond_to?(:headers)
|
|
312
|
+
self
|
|
313
|
+
rescue Faraday::ConnectionFailed
|
|
314
|
+
raise unless events?
|
|
315
|
+
|
|
316
|
+
self
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def feed(chunk, _size = nil, env = nil)
|
|
320
|
+
Support::Cancellation.check
|
|
321
|
+
@headers = env.response_headers if env&.response_headers
|
|
322
|
+
return @body << chunk if @events == false
|
|
323
|
+
|
|
324
|
+
if @events.nil?
|
|
325
|
+
@body << chunk
|
|
326
|
+
return if @body.strip.empty?
|
|
327
|
+
|
|
328
|
+
@events = !@body.lstrip.start_with?('{', '[')
|
|
329
|
+
chunk = @body
|
|
330
|
+
end
|
|
331
|
+
return unless @events
|
|
332
|
+
|
|
333
|
+
@parser.feed(chunk) { |_type, data| receive_event(data) }
|
|
334
|
+
throw self if answer
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def replies
|
|
338
|
+
receive(@body) if @events == false && @replies.empty?
|
|
339
|
+
@replies
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def answer
|
|
343
|
+
replies.find { |reply| !reply.key?('method') && reply['id'] == id }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def interrupted?
|
|
347
|
+
events? && answer.nil?
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def last_event_id
|
|
351
|
+
@parser.last_event_id unless @parser.last_event_id.empty?
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def retry_after
|
|
355
|
+
@parser.reconnection_time / 1000.0 if @parser.reconnection_time
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
private
|
|
359
|
+
|
|
360
|
+
def events?
|
|
361
|
+
@events == true
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def receive_event(data)
|
|
365
|
+
receive(data) unless data.empty?
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def receive(data)
|
|
369
|
+
parsed = JSON.parse(data)
|
|
370
|
+
(parsed.is_a?(Array) ? parsed : [parsed]).grep(Hash).each do |reply|
|
|
371
|
+
reply['method'] ? @on_message&.call(reply) : @replies << reply
|
|
372
|
+
end
|
|
373
|
+
rescue JSON::ParserError
|
|
374
|
+
RubyLLM.logger.debug { 'MCP server sent a message that is not JSON' }
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# A server's request for input from the user while it works on a call,
|
|
6
|
+
# received by MCP.before_input_request callbacks. A form request asks
|
|
7
|
+
# for values; a URL request asks the user to visit a page, such as to
|
|
8
|
+
# connect an account.
|
|
9
|
+
#
|
|
10
|
+
# before_input_request do |request|
|
|
11
|
+
# if request.url?
|
|
12
|
+
# system "open", request.url
|
|
13
|
+
# request.answer
|
|
14
|
+
# else
|
|
15
|
+
# request.answer(environment: "staging")
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# In a chat, a request no callback answers pauses the tool call until
|
|
20
|
+
# Chat#answer or Chat#decline settles it; see Chat#pending_inputs.
|
|
21
|
+
class InputRequest
|
|
22
|
+
include Support::Inspectable
|
|
23
|
+
|
|
24
|
+
# A value a form request asks for.
|
|
25
|
+
Field = Struct.new(:name, :type, :title, :description, :required, :choices, :default, keyword_init: true) do
|
|
26
|
+
# Returns whether the server needs this value.
|
|
27
|
+
def required?
|
|
28
|
+
required == true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Why the server needs the input.
|
|
33
|
+
attr_reader :message
|
|
34
|
+
|
|
35
|
+
# The page a URL request asks the user to visit, or +nil+.
|
|
36
|
+
attr_reader :url
|
|
37
|
+
|
|
38
|
+
# The values a form request asks for, as Field objects.
|
|
39
|
+
attr_reader :fields
|
|
40
|
+
|
|
41
|
+
attr_reader :key, :response # :nodoc:
|
|
42
|
+
|
|
43
|
+
# The ToolCall paused on this request, when it came from a chat.
|
|
44
|
+
attr_reader :tool_call
|
|
45
|
+
|
|
46
|
+
def self.from_h(data, tool_call: nil) # :nodoc:
|
|
47
|
+
new(data['key'], data['params'], response: data['response'], tool_call:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(key, params, response: nil, tool_call: nil) # :nodoc:
|
|
51
|
+
@key = key
|
|
52
|
+
@params = params
|
|
53
|
+
@response = response
|
|
54
|
+
@tool_call = tool_call
|
|
55
|
+
@message = params['message']
|
|
56
|
+
@url = params['url'] if params['mode'] == 'url'
|
|
57
|
+
@fields = fields_from(params['requestedSchema'] || {})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns whether the server asks the user to visit #url.
|
|
61
|
+
def url?
|
|
62
|
+
!url.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns whether the server asks for #fields.
|
|
66
|
+
def form?
|
|
67
|
+
!url?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Accepts the request. A form request takes the values as keywords,
|
|
71
|
+
# and the fields you leave out take the defaults the server gave
|
|
72
|
+
# them; a URL request takes none, meaning the user agreed to visit
|
|
73
|
+
# the page.
|
|
74
|
+
def answer(**values)
|
|
75
|
+
@response = { action: 'accept', content: (with_defaults(values) unless url?) }.compact
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Declines the request.
|
|
79
|
+
def decline
|
|
80
|
+
@response = { action: 'decline' }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns whether the request has been answered or declined.
|
|
84
|
+
def answered?
|
|
85
|
+
!response.nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_h # :nodoc:
|
|
89
|
+
{ 'key' => key, 'params' => @params, 'response' => response&.transform_keys(&:to_s) }.compact
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def with_defaults(values)
|
|
95
|
+
fields.reject { |field| field.default.nil? }.to_h { |field| [field.name, field.default] }
|
|
96
|
+
.merge(values).transform_keys(&:to_s)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def fields_from(schema)
|
|
100
|
+
required = Array(schema['required'])
|
|
101
|
+
schema.fetch('properties', {}).map do |name, property|
|
|
102
|
+
Field.new(name: name.to_sym, type: property['type'], title: property['title'],
|
|
103
|
+
description: property['description'], required: required.include?(name),
|
|
104
|
+
choices: choices(property), default: property['default'])
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def choices(property)
|
|
109
|
+
options = property['enum'] || Array(property['oneOf'] || property.dig('items', 'anyOf')).map { |o| o['const'] }
|
|
110
|
+
options unless options.empty?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def inspect_attributes
|
|
114
|
+
{ message:, url:, fields: fields.map(&:name) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# Raised when a server needs input from the user and no
|
|
6
|
+
# MCP.before_input_request callback answered. Its message includes what
|
|
7
|
+
# the server asked for, and #requests has the MCP::InputRequest objects.
|
|
8
|
+
# In a chat, the tool call pauses instead, and Chat#pending_inputs
|
|
9
|
+
# returns the requests.
|
|
10
|
+
class InputRequiredError < Error
|
|
11
|
+
def initialize(server, input) # :nodoc:
|
|
12
|
+
@input = input
|
|
13
|
+
asks = requests.map { |request| [request.message, request.url].compact.join(' ') }
|
|
14
|
+
super("#{server} needs input from the user: #{asks.join('; ')}")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The unanswered MCP::InputRequest objects.
|
|
18
|
+
def requests
|
|
19
|
+
@input['requests'].reject(&:answered?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns everything needed to answer the requests later and resume
|
|
23
|
+
# the call, as a Hash that serializes to JSON.
|
|
24
|
+
def to_h # :nodoc:
|
|
25
|
+
@input.merge('requests' => @input['requests'].map(&:to_h)).compact
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# Keeps a subscription to a server's changes open in a thread of its
|
|
6
|
+
# own, so the notifications reach the MCP while chats and requests go
|
|
7
|
+
# on. #start returns once the server acknowledges the subscription.
|
|
8
|
+
# When a stream ends, the listener subscribes again after a delay that
|
|
9
|
+
# starts at a second and doubles up to a minute, and gives up when the
|
|
10
|
+
# server agrees to send nothing or does not know the method. Changes
|
|
11
|
+
# made in between are lost, so once the server acknowledges again, the
|
|
12
|
+
# +resumed+ callable receives what the listener listens to.
|
|
13
|
+
#
|
|
14
|
+
# Stopping raises CancelledError in the listener's thread. Callbacks run
|
|
15
|
+
# with that deferred, so a stop never cuts one short, and inside the
|
|
16
|
+
# Rails executor when Rails is loaded. The thread reports no progress
|
|
17
|
+
# to a chat that started it. A forked child does not inherit the
|
|
18
|
+
# thread, so it listens again only when started again.
|
|
19
|
+
class Listener # :nodoc:
|
|
20
|
+
RETRY_DELAY = 1
|
|
21
|
+
MAX_RETRY_DELAY = 60
|
|
22
|
+
STOP_TIMEOUT = 5
|
|
23
|
+
|
|
24
|
+
def initialize(client, name:, timeout:, resumed: nil, &on_notification)
|
|
25
|
+
@client = client
|
|
26
|
+
@name = name
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
@resumed = resumed
|
|
29
|
+
@on_notification = on_notification
|
|
30
|
+
@control = Mutex.new
|
|
31
|
+
@lock = Mutex.new
|
|
32
|
+
@acknowledged = ConditionVariable.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Listens for +changes+, a subscriptions/listen filter, instead of
|
|
36
|
+
# whatever the listener heard before. Returns the changes the server
|
|
37
|
+
# agreed to send, or +nil+ when there is nothing to listen for.
|
|
38
|
+
def start(changes)
|
|
39
|
+
@control.synchronize do
|
|
40
|
+
next @state if running? && changes == @changes
|
|
41
|
+
|
|
42
|
+
halt
|
|
43
|
+
launch(changes) unless changes.empty?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stop
|
|
48
|
+
@control.synchronize { halt }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def running?
|
|
54
|
+
@pid == Process.pid && @thread&.alive?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def launch(changes)
|
|
58
|
+
@changes = changes
|
|
59
|
+
@pid = Process.pid
|
|
60
|
+
@state = nil
|
|
61
|
+
@thread = Thread.new { Support::ProgressReporter.listen(nil) { run } }
|
|
62
|
+
@thread.name = 'ruby_llm-mcp-listener'
|
|
63
|
+
@thread.report_on_exception = false
|
|
64
|
+
acknowledgment
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def acknowledgment
|
|
68
|
+
settled = settle_within(@timeout)
|
|
69
|
+
return @state if @state.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
halt
|
|
72
|
+
raise @state if settled
|
|
73
|
+
|
|
74
|
+
raise Error, "#{@name} did not acknowledge the subscription"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def settle_within(timeout)
|
|
78
|
+
deadline = monotonic_now + timeout
|
|
79
|
+
@lock.synchronize do
|
|
80
|
+
until @state
|
|
81
|
+
remaining = deadline - monotonic_now
|
|
82
|
+
return false unless remaining.positive?
|
|
83
|
+
|
|
84
|
+
@acknowledged.wait(@lock, remaining)
|
|
85
|
+
end
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run
|
|
91
|
+
delay = RETRY_DELAY
|
|
92
|
+
loop do
|
|
93
|
+
opened = monotonic_now
|
|
94
|
+
ending = subscribe
|
|
95
|
+
break unless again?(ending)
|
|
96
|
+
|
|
97
|
+
delay = RETRY_DELAY if monotonic_now - opened >= MAX_RETRY_DELAY
|
|
98
|
+
pause(delay * (1 + rand) / 2, ending)
|
|
99
|
+
delay = [delay * 2, MAX_RETRY_DELAY].min
|
|
100
|
+
end
|
|
101
|
+
rescue CancelledError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def pause(seconds, ending)
|
|
106
|
+
reason = ending ? "stopped sending changes: #{ending.message}" : 'ended the subscription'
|
|
107
|
+
RubyLLM.logger.warn { "#{@name} #{reason}; listening again in #{seconds.round(1)}s" }
|
|
108
|
+
sleep(seconds)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def subscribe
|
|
112
|
+
@client.listen(@changes) { |notification| receive(notification) }
|
|
113
|
+
nil
|
|
114
|
+
rescue CancelledError
|
|
115
|
+
raise
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
e
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def receive(notification)
|
|
121
|
+
acknowledged = notification['method'] == Client::ACKNOWLEDGED
|
|
122
|
+
listened = notification.dig('params', 'notifications') || {}
|
|
123
|
+
resumed = acknowledged && @resumed && @state.is_a?(Hash)
|
|
124
|
+
Thread.handle_interrupt(CancelledError => :never) do
|
|
125
|
+
dispatch(@on_notification, notification)
|
|
126
|
+
dispatch(@resumed, listened) if resumed
|
|
127
|
+
end
|
|
128
|
+
settle(listened) if acknowledged
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def dispatch(callback, argument)
|
|
132
|
+
executor = defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.executor
|
|
133
|
+
executor ? executor.wrap { callback.call(argument) } : callback.call(argument)
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
RubyLLM.logger.error { "#{@name} after_change callback failed: #{e.class}: #{e.message}" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def again?(ending)
|
|
139
|
+
unless @state.is_a?(Hash)
|
|
140
|
+
settle(ending || Error.new("#{@name} ended the subscription before acknowledging it"))
|
|
141
|
+
return false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
!@state.empty? && !(ending.is_a?(Error) && ending.code == Client::METHOD_NOT_FOUND)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def settle(state)
|
|
148
|
+
@lock.synchronize do
|
|
149
|
+
@state = state
|
|
150
|
+
@acknowledged.broadcast
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def halt
|
|
155
|
+
thread = @thread
|
|
156
|
+
@thread = nil
|
|
157
|
+
return unless thread&.alive? && @pid == Process.pid
|
|
158
|
+
|
|
159
|
+
thread.raise(CancelledError)
|
|
160
|
+
thread.join(STOP_TIMEOUT) unless thread == Thread.current
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def monotonic_now
|
|
164
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|