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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # Mirrors tool arguments marked with +x-mcp-header+ into Mcp-Param-*
6
+ # HTTP headers, as 2026-07-28 requires, so intermediaries can route on
7
+ # them. Tools that declare invalid headers are left out entirely.
8
+ module ParamHeaders # :nodoc:
9
+ TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
10
+ TYPES = %w[string integer boolean].freeze
11
+ SCHEMA_MAPS = %w[$defs definitions patternProperties dependentSchemas dependencies].freeze
12
+ SCHEMA_VALUES = %w[
13
+ items prefixItems additionalItems contains additionalProperties unevaluatedItems unevaluatedProperties
14
+ propertyNames allOf anyOf oneOf not if then else contentSchema
15
+ ].freeze
16
+
17
+ module_function
18
+
19
+ def valid?(definition)
20
+ declared = declarations(definition)
21
+ names = declared.map { |_, header, _| header.to_s.downcase }
22
+ valid = declared.all? { |path, header, schema| valid_declaration?(path, header, schema) }
23
+ return true if valid && names.uniq.size == names.size
24
+
25
+ RubyLLM.logger.warn { "Ignoring MCP tool #{definition['name']}: it declares invalid x-mcp-header values" }
26
+ false
27
+ end
28
+
29
+ def for(definition, arguments)
30
+ declarations(definition).each_with_object({}) do |(path, header, _), headers|
31
+ value = argument_at(arguments, path)
32
+ headers[header] = value.to_s unless value.nil?
33
+ end
34
+ end
35
+
36
+ def declarations(definition)
37
+ schema_declarations(definition['inputSchema'], [])
38
+ end
39
+
40
+ def valid_declaration?(path, header, schema)
41
+ path&.any? && header.is_a?(String) && header.match?(TOKEN) && TYPES.include?(schema['type'])
42
+ end
43
+
44
+ def schema_declarations(schema, path)
45
+ return [] unless schema.is_a?(Hash)
46
+
47
+ declared = schema.key?('x-mcp-header') ? [[path, schema['x-mcp-header'], schema]] : []
48
+ declared + property_declarations(schema['properties'], path) +
49
+ unreachable_schemas(schema).flat_map { |child| schema_declarations(child, nil) }
50
+ end
51
+
52
+ def property_declarations(properties, path)
53
+ return [] unless properties.is_a?(Hash)
54
+
55
+ properties.flat_map do |property, child|
56
+ schema_declarations(child, path && [*path, property])
57
+ end
58
+ end
59
+
60
+ def unreachable_schemas(schema)
61
+ schema.flat_map do |keyword, children|
62
+ case keyword
63
+ when *SCHEMA_MAPS
64
+ children.is_a?(Hash) ? children.values : []
65
+ when *SCHEMA_VALUES
66
+ children.is_a?(Array) ? children : [children]
67
+ else
68
+ []
69
+ end
70
+ end
71
+ end
72
+
73
+ def argument_at(arguments, path)
74
+ path.reduce(arguments) do |value, property|
75
+ break unless value.is_a?(Hash)
76
+
77
+ argument = value[property.to_sym]
78
+ argument.nil? ? value[property] : argument
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # A prompt an MCP server offers: messages written by the server and
6
+ # filled in with your arguments. Ask a chat with it like a question.
7
+ #
8
+ # github.prompts # => [#<RubyLLM::MCP::Prompt name: "code_review", ...>]
9
+ # review = github.prompt(:code_review, code: diff)
10
+ # review.messages # => [#<RubyLLM::Message role: :user, ...>]
11
+ # chat.ask review
12
+ #
13
+ class Prompt
14
+ include Support::Inspectable
15
+
16
+ # An argument a prompt takes.
17
+ Argument = Struct.new(:name, :description, :required, keyword_init: true) do
18
+ # Returns whether the prompt needs this argument.
19
+ def required?
20
+ required == true
21
+ end
22
+ end
23
+
24
+ # The prompt's name.
25
+ attr_reader :name
26
+
27
+ # A human-readable title, or +nil+.
28
+ attr_reader :title
29
+
30
+ # What the prompt does, or +nil+.
31
+ attr_reader :description
32
+
33
+ # The arguments the prompt takes, as Argument objects.
34
+ attr_reader :arguments
35
+
36
+ def initialize(mcp, data, messages: nil) # :nodoc:
37
+ @mcp = mcp
38
+ @name = data['name']
39
+ @title = data['title']
40
+ @description = data['description']
41
+ @arguments = Array(data['arguments']).map do |argument|
42
+ Argument.new(name: argument['name'].to_sym, description: argument['description'],
43
+ required: argument['required'])
44
+ end
45
+ @messages = messages
46
+ end
47
+
48
+ # Returns the prompt's messages as Message objects. A prompt from
49
+ # MCP#prompts has none until you fill it in with MCP#prompt.
50
+ def messages
51
+ @messages || []
52
+ end
53
+
54
+ # Asks the server to complete an argument's partial value. Pass one
55
+ # argument to complete and any others that are already filled in.
56
+ # Returns an Array of suggested values.
57
+ #
58
+ # github.prompts.first.suggest(language: "ru") # => ["ruby", "rust"]
59
+ #
60
+ def suggest(**arguments)
61
+ @mcp.suggest({ type: 'ref/prompt', name: }, arguments)
62
+ end
63
+
64
+ private
65
+
66
+ def inspect_attributes
67
+ { name:, arguments: arguments.map(&:name), messages: messages.size.nonzero? }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # A resource on an MCP server: a file, a record, or any data the server
6
+ # names with a URI. Pass one to a chat like any attachment, or save it.
7
+ #
8
+ # readme = files.resource("file:///project/README.md")
9
+ # readme.content # => "# My Project..."
10
+ # readme.save("README.md")
11
+ # chat.ask "Summarize this", with: readme
12
+ #
13
+ # Resources from MCP#resources are read from the server the first time
14
+ # their content is needed.
15
+ class Resource
16
+ include Support::Inspectable
17
+
18
+ # The URI that identifies the resource on its server.
19
+ attr_reader :uri
20
+
21
+ # The resource's name.
22
+ attr_reader :name
23
+
24
+ # A human-readable title, or +nil+.
25
+ attr_reader :title
26
+
27
+ # What the resource is, or +nil+.
28
+ attr_reader :description
29
+
30
+ # The MIME type the server reports, or +nil+.
31
+ attr_reader :mime_type
32
+
33
+ # The +_meta+ the server sent with the resource, as a Hash with String
34
+ # keys, empty when there is none. A resource read with MCP#resource
35
+ # has the +_meta+ of its contents; one from MCP#resources has the
36
+ # +_meta+ of the listing.
37
+ attr_reader :meta
38
+
39
+ def initialize(mcp, data) # :nodoc:
40
+ @mcp = mcp
41
+ @uri = data['uri']
42
+ @name = data['name'] || Content.filename(uri)
43
+ @title = data['title']
44
+ @description = data['description']
45
+ @mime_type = data['mimeType']
46
+ @meta = data['_meta'] || {}
47
+ @data = data
48
+ end
49
+
50
+ # Returns the resource's content: text for text resources, bytes for
51
+ # binary ones.
52
+ def content
53
+ @content ||= if @data.key?('text') then @data['text']
54
+ elsif @data.key?('blob') then Base64.decode64(@data['blob'])
55
+ else @mcp.resource(uri).content
56
+ end
57
+ end
58
+
59
+ # Returns the content as a String of bytes.
60
+ def to_blob
61
+ content.b
62
+ end
63
+
64
+ # Writes the content to +path+, expanding it first. Returns +path+.
65
+ def save(path)
66
+ File.binwrite(File.expand_path(path), to_blob)
67
+ path
68
+ end
69
+
70
+ # Returns the resource as an Attachment, which is how chats take it
71
+ # through +with:+.
72
+ def to_attachment
73
+ filename = Content.filename(uri)
74
+ Attachment.new(StringIO.new(to_blob), filename: filename.empty? ? name : filename)
75
+ end
76
+
77
+ private
78
+
79
+ def inspect_attributes
80
+ { uri:, name:, mime_type: }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # A family of resources on an MCP server, named by a URI template.
6
+ #
7
+ # template = files.resource_templates.first
8
+ # template.uri # => "file:///{path}"
9
+ # template.suggest(path: "app/mo") # => ["app/models/"]
10
+ # files.resource(template.uri, path: "Gemfile")
11
+ #
12
+ class ResourceTemplate
13
+ include Support::Inspectable
14
+
15
+ OPERATORS = {
16
+ '' => [',', false], '+' => [',', true], '#' => [',', true], '/' => ['/', false],
17
+ '.' => ['.', false], ';' => [';', false], '?' => ['&', false], '&' => ['&', false]
18
+ }.freeze
19
+ NAMED_OPERATORS = %w[? & ;].freeze
20
+ private_constant :OPERATORS, :NAMED_OPERATORS
21
+
22
+ # The URI template, such as <tt>"file:///{path}"</tt>.
23
+ attr_reader :uri
24
+
25
+ # The template's name.
26
+ attr_reader :name
27
+
28
+ # A human-readable title, or +nil+.
29
+ attr_reader :title
30
+
31
+ # What the resources are, or +nil+.
32
+ attr_reader :description
33
+
34
+ # The MIME type of the resources, or +nil+.
35
+ attr_reader :mime_type
36
+
37
+ def self.expand(template, variables) # :nodoc:
38
+ variables = variables.transform_keys(&:to_s)
39
+ template.gsub(%r{\{([+#./;?&]?)([^{}]+)\}}) do
40
+ expand_expression(Regexp.last_match(1), Regexp.last_match(2).split(','), variables)
41
+ end
42
+ end
43
+
44
+ def self.expand_expression(operator, specs, variables) # :nodoc:
45
+ separator, reserved = OPERATORS.fetch(operator)
46
+ values = specs.filter_map { |spec| expand_variable(spec, operator, separator, reserved, variables) }
47
+ return '' if values.empty?
48
+
49
+ prefix = ['', '+'].include?(operator) ? '' : operator
50
+ "#{prefix}#{values.join(separator)}"
51
+ end
52
+
53
+ # Expands one variable with its prefix (+:3+) and explode (+*+)
54
+ # modifiers. Array values join with the operator's separator when
55
+ # exploded and with commas otherwise.
56
+ def self.expand_variable(spec, operator, separator, reserved, variables) # :nodoc:
57
+ explode = spec.end_with?('*')
58
+ name, length = spec.delete_suffix('*').split(':')
59
+ return unless variables.key?(name)
60
+
61
+ parts = Array(variables[name]).map { |part| encode(length ? part.to_s[0, length.to_i] : part.to_s, reserved) }
62
+ named = NAMED_OPERATORS.include?(operator)
63
+ return parts.map { |part| named ? "#{name}=#{part}" : part }.join(separator) if explode
64
+
65
+ named ? "#{name}=#{parts.join(',')}" : parts.join(',')
66
+ end
67
+
68
+ def self.encode(value, reserved) # :nodoc:
69
+ pattern = reserved ? %r{[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]} : /[^A-Za-z0-9\-._~]/
70
+ value.gsub(pattern) { |char| char.bytes.map { |byte| format('%%%02X', byte) }.join }
71
+ end
72
+
73
+ def initialize(mcp, data) # :nodoc:
74
+ @mcp = mcp
75
+ @uri = data['uriTemplate']
76
+ @name = data['name']
77
+ @title = data['title']
78
+ @description = data['description']
79
+ @mime_type = data['mimeType']
80
+ end
81
+
82
+ # Asks the server to complete a variable's partial value. Pass one
83
+ # variable to complete and any others that are already filled in.
84
+ # Returns an Array of suggested values.
85
+ #
86
+ # template.suggest(path: "app/mo") # => ["app/models/"]
87
+ #
88
+ def suggest(**variables)
89
+ @mcp.suggest({ type: 'ref/resource', uri: }, variables)
90
+ end
91
+
92
+ private
93
+
94
+ def inspect_attributes
95
+ { uri:, name: }
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # The result of calling an MCP tool. Text, files, and structured data
6
+ # each have their own reader.
7
+ #
8
+ # result = linear.list_issues(query: "bug")
9
+ # result.text # => "Found 3 issues..."
10
+ # result.structured # => { "issues" => [...] }
11
+ # result.attachments # => [#<RubyLLM::Attachment ...>]
12
+ # result.meta # => { "com.linear/request_id" => "..." }
13
+ #
14
+ # In a chat, the result of a tool with a UI stays on its tool result
15
+ # message as Message#mcp_result, so your app can render the UI again.
16
+ class Result
17
+ include Support::Inspectable
18
+
19
+ # The text of the result, with text blocks joined by blank lines.
20
+ attr_reader :text
21
+
22
+ # Images, audio, and embedded files, as Attachment objects.
23
+ attr_reader :attachments
24
+
25
+ # The structured content, parsed from JSON, or +nil+.
26
+ attr_reader :structured
27
+
28
+ # The result's +_meta+ as the server sent it: a Hash with String
29
+ # keys, empty when there is none.
30
+ attr_reader :meta
31
+
32
+ # The URI of the UI that renders the result, from its tool's
33
+ # MCP::Tool#ui_uri, or +nil+ for a tool without one.
34
+ attr_reader :ui_uri
35
+
36
+ def self.load(data) # :nodoc:
37
+ new(data['result'], data['ui_uri'])
38
+ end
39
+
40
+ def initialize(data, ui_uri = nil) # :nodoc:
41
+ @data = data
42
+ @ui_uri = ui_uri
43
+ @structured = data['structuredContent']
44
+ @meta = data['_meta'] || {}
45
+ @text, @attachments = Content.read(data['content'])
46
+ end
47
+
48
+ # Returns whether the tool reported a failure.
49
+ def error?
50
+ @data['isError'] == true
51
+ end
52
+
53
+ # Returns what a chat sends to the model: the text, or the structured
54
+ # content as JSON when there is no text, followed by any attachments.
55
+ def content
56
+ body = text.empty? && structured ? JSON.generate(structured) : text
57
+ attachments.empty? ? body : [body, *attachments]
58
+ end
59
+
60
+ # Returns the result as the server sent it.
61
+ def to_h
62
+ @data
63
+ end
64
+
65
+ def dump # :nodoc:
66
+ { 'ui_uri' => ui_uri, 'result' => to_h }
67
+ end
68
+
69
+ private
70
+
71
+ def inspect_attributes
72
+ { text:, structured:, attachments: attachments.size.nonzero?, error: error? || nil, ui_uri: }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # Raised by a transport when the session of a server that predates
6
+ # 2026-07-28 has ended, so the client starts a new one: the server
7
+ # answered 404, or its stdio process exited.
8
+ class SessionExpired < Error; end # :nodoc:
9
+ end
10
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/wait'
4
+ require 'open3'
5
+
6
+ module RubyLLM
7
+ class MCP
8
+ # stdio. The server is a child process that reads one JSON-RPC message
9
+ # per line on stdin and writes one per line on stdout. Reads never block
10
+ # past the deadline, even on a partial line. It starts on the
11
+ # first request, restarts after it exits, and handles one request at a
12
+ # time. Its stderr is the parent's. A server that predates 2026-07-28
13
+ # keeps its session for the life of its process, so after a restart its
14
+ # requests raise SessionExpired until the client initializes it again.
15
+ #
16
+ # Subscriptions share the channel, so whichever thread reads a message
17
+ # that belongs to one hands it to the subscription's listener: a
18
+ # request waiting for its answer, or the listener itself while no
19
+ # request reads. A listener stops when CancelledError is raised in its
20
+ # thread, so it reads and writes with that deferred.
21
+ class Stdio # :nodoc:
22
+ SHUTDOWN_GRACE = 2
23
+ CHECK_INTERVAL = 0.5
24
+ SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId'
25
+
26
+ # The messages of the subscription opened by request +id+, or with no
27
+ # +id+, the changes a server that predates subscriptions announces.
28
+ Subscription = Struct.new(:id, :messages) do
29
+ def claims?(message)
30
+ return !message.key?('id') && Client::CHANGES.include?(message['method']) if id.nil?
31
+
32
+ params = message['params'].is_a?(Hash) ? message['params'] : {}
33
+ params.dig('_meta', SUBSCRIPTION_ID) == id || answer?(message) ||
34
+ (message['method'] == 'notifications/cancelled' && params['requestId'] == id)
35
+ end
36
+
37
+ def answer?(message)
38
+ !id.nil? && message['id'] == id && !message.key?('method')
39
+ end
40
+ end
41
+
42
+ def initialize(command, env: {}, directory: nil, timeout: nil, config: RubyLLM.config)
43
+ @command = Array(command).map(&:to_s)
44
+ @env = env.to_h { |key, value| [key.to_s, value.to_s] }
45
+ @directory = directory&.to_s
46
+ @timeout = timeout || config.request_timeout
47
+ @lock = Mutex.new
48
+ @subscriptions = []
49
+ end
50
+
51
+ def request(message, version: nil, timeout: nil, **, &)
52
+ @lock.synchronize do
53
+ check_session(version)
54
+ write(message)
55
+ @session = @process if message[:method] == 'initialize'
56
+ await(message[:id], timeout || @timeout, &)
57
+ end
58
+ end
59
+
60
+ def notify(message, **)
61
+ @lock.synchronize { write(message) }
62
+ nil
63
+ end
64
+
65
+ def cancel(notification, **)
66
+ notify(notification)
67
+ end
68
+
69
+ def listen(message, **)
70
+ subscription = Subscription.new(message&.dig(:id), Queue.new)
71
+ process = uninterrupted { @lock.synchronize { subscribe(subscription, message) } }
72
+ loop do
73
+ until subscription.messages.empty?
74
+ reply = subscription.messages.pop
75
+ return reply if subscription.answer?(reply)
76
+
77
+ yield reply
78
+ end
79
+ raise Error, "#{name} exited" unless @process.equal?(process)
80
+
81
+ receive(subscription)
82
+ end
83
+ rescue IOError
84
+ raise Error, "#{name} exited"
85
+ ensure
86
+ uninterrupted { @lock.synchronize { @subscriptions.delete(subscription) } }
87
+ end
88
+
89
+ def close
90
+ @lock.synchronize { stop }
91
+ end
92
+
93
+ private
94
+
95
+ def check_session(version)
96
+ return if version.nil? || version == Client::VERSION || (@process&.alive? && @process.equal?(@session))
97
+
98
+ raise SessionExpired, "#{name} exited"
99
+ end
100
+
101
+ def subscribe(subscription, message)
102
+ raise Error, "#{name} exited" unless message || @process&.alive?
103
+
104
+ @subscriptions << subscription
105
+ write(message) if message
106
+ @process
107
+ end
108
+
109
+ def receive(subscription)
110
+ read = uninterrupted do
111
+ next false unless @lock.try_lock
112
+
113
+ begin
114
+ drain
115
+ ensure
116
+ @lock.unlock
117
+ end
118
+ true
119
+ end
120
+ return sleep(CHECK_INTERVAL) unless read
121
+
122
+ @stdout.wait_readable(CHECK_INTERVAL) if subscription.messages.empty?
123
+ end
124
+
125
+ def drain
126
+ loop do
127
+ while (line = @buffer.slice!(/\A[^\n]*\n/))
128
+ message = parse(line)
129
+ handled?(message) if message
130
+ end
131
+ chunk = @stdout.read_nonblock(65_536, exception: false)
132
+ return if chunk == :wait_readable
133
+
134
+ exited unless chunk
135
+ @buffer << chunk
136
+ end
137
+ end
138
+
139
+ def handled?(message)
140
+ return true if route(message)
141
+ return false unless message.key?('method') && message.key?('id')
142
+
143
+ answer(message)
144
+ true
145
+ end
146
+
147
+ def route(message)
148
+ @subscriptions.find { |subscription| subscription.claims?(message) }&.messages&.push(message)
149
+ end
150
+
151
+ def uninterrupted(&)
152
+ Thread.handle_interrupt(CancelledError => :never, &)
153
+ end
154
+
155
+ def await(id, timeout)
156
+ deadline = monotonic_now + timeout
157
+ loop do
158
+ reply = read(deadline)
159
+ return reply if reply['id'] == id && !reply.key?('method')
160
+
161
+ yield reply if !handled?(reply) && reply.key?('method') && block_given?
162
+ end
163
+ end
164
+
165
+ def write(message)
166
+ start unless @process&.alive?
167
+ @stdin.puts JSON.generate(message)
168
+ @stdin.flush
169
+ rescue Errno::EPIPE, IOError
170
+ stop
171
+ raise Error, "#{name} exited"
172
+ end
173
+
174
+ def read(deadline)
175
+ loop do
176
+ message = parse(next_line(deadline))
177
+ return message if message
178
+ end
179
+ end
180
+
181
+ def parse(line)
182
+ return if line.strip.empty?
183
+
184
+ message = JSON.parse(line)
185
+ message if message.is_a?(Hash)
186
+ rescue JSON::ParserError
187
+ RubyLLM.logger.debug { "#{name} wrote a line that is not JSON" }
188
+ nil
189
+ end
190
+
191
+ def next_line(deadline)
192
+ loop do
193
+ line = @buffer.slice!(/\A[^\n]*\n/)
194
+ return line if line
195
+
196
+ remaining = deadline - monotonic_now
197
+ raise Error, "#{name} did not answer in time" unless remaining.positive?
198
+
199
+ Support::Cancellation.check
200
+ next unless @stdout.wait_readable([remaining, CHECK_INTERVAL].min)
201
+
202
+ chunk = @stdout.read_nonblock(65_536, exception: false)
203
+ next if chunk == :wait_readable
204
+
205
+ exited unless chunk
206
+ @buffer << chunk
207
+ end
208
+ end
209
+
210
+ def exited
211
+ stop
212
+ raise Error, "#{name} exited"
213
+ end
214
+
215
+ def answer(request)
216
+ write(Client.reply(request))
217
+ end
218
+
219
+ def start
220
+ options = @directory ? { chdir: @directory } : {}
221
+ @stdin, @stdout, @process = Open3.popen2(@env, *@command, **options)
222
+ @buffer = +''
223
+ end
224
+
225
+ def stop
226
+ return unless @process
227
+
228
+ @stdin.close unless @stdin.closed?
229
+ @stdout.close unless @stdout.closed?
230
+ terminate unless @process.join(SHUTDOWN_GRACE)
231
+ @process = nil
232
+ end
233
+
234
+ def terminate
235
+ Process.kill('TERM', @process.pid)
236
+ Process.kill('KILL', @process.pid) unless @process.join(SHUTDOWN_GRACE)
237
+ rescue Errno::ESRCH
238
+ nil
239
+ end
240
+
241
+ def name
242
+ File.basename(@command.first.to_s)
243
+ end
244
+
245
+ def monotonic_now
246
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
247
+ end
248
+ end
249
+ end
250
+ end