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.
Files changed (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. 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