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,1101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # An MCP is a client for a Model Context Protocol server. Describe the
5
+ # server to connect to in a subclass, the way you describe a Tool or an
6
+ # Agent, then hand an instance to a chat:
7
+ #
8
+ # class Linear < RubyLLM::MCP
9
+ # url "https://mcp.linear.app/mcp"
10
+ # inputs :user
11
+ # bearer_token { user.linear_token }
12
+ # end
13
+ #
14
+ # linear = Linear.new(user: current_user)
15
+ # linear.tools # => [#<RubyLLM::MCP::Tool name: "list_issues", ...>, ...]
16
+ # linear.list_issues(query: "bug") # => #<RubyLLM::MCP::Result ...>
17
+ #
18
+ # A +url+ connects over Streamable HTTP; a +command+ starts a local
19
+ # server that speaks over stdio:
20
+ #
21
+ # class Files < RubyLLM::MCP
22
+ # command "npx", "-y", "@modelcontextprotocol/server-filesystem", "."
23
+ # end
24
+ #
25
+ # A +transport+ carries the messages any other way, such as through a
26
+ # tunnel to a server behind a firewall.
27
+ #
28
+ # Settings that depend on runtime state take a block or a method name,
29
+ # evaluated on the instance, so declared ::inputs and private methods are
30
+ # available. RubyLLM.mcp builds one inline when a class is not worth
31
+ # writing.
32
+ class MCP
33
+ include Support::Inspectable
34
+
35
+ INPUT_ROUNDS = 10
36
+ INPUT_REQUESTS = %i[form url].freeze
37
+ INLINE_SETTINGS = %i[url command transport bearer_token directory timeout prefix input_requests log_level].freeze
38
+ LOG_LEVELS = {
39
+ debug: Logger::DEBUG, info: Logger::INFO, notice: Logger::INFO, warning: Logger::WARN,
40
+ error: Logger::ERROR, critical: Logger::FATAL, alert: Logger::FATAL, emergency: Logger::FATAL
41
+ }.freeze
42
+ EXTENSIONS = {
43
+ apps: [Apps::EXTENSION, { 'mimeTypes' => [Apps::MIME_TYPE] }],
44
+ tasks: ['io.modelcontextprotocol/tasks', {}]
45
+ }.freeze
46
+ POLL_INTERVAL = 1
47
+ UNKNOWN_TOOL_ERRORS = [-32_601, -32_602].freeze
48
+
49
+ SETTINGS = %i[
50
+ @url @command @directory @env @headers @bearer_token @timeout @input_names @only @except @prefix
51
+ @tool_declarations @approvals @deferrals @callbacks @oauth @input_requests @extensions @log_level
52
+ ].freeze
53
+ private_constant :SETTINGS, :INPUT_ROUNDS, :INPUT_REQUESTS, :INLINE_SETTINGS, :EXTENSIONS, :POLL_INTERVAL,
54
+ :LOG_LEVELS, :UNKNOWN_TOOL_ERRORS
55
+
56
+ class << self
57
+ attr_writer :default_name # :nodoc:
58
+
59
+ def inherited(subclass) # :nodoc:
60
+ super
61
+ SETTINGS.each do |setting|
62
+ value = instance_variable_get(setting)
63
+ subclass.instance_variable_set(setting, value.dup) unless value.nil?
64
+ end
65
+ subclass.transport(@transport) if @transport
66
+ end
67
+
68
+ # Sets the server's Streamable HTTP endpoint. Plain HTTP is only
69
+ # allowed for loopback addresses. Called with no argument, returns the
70
+ # configured value.
71
+ #
72
+ # url "https://mcp.linear.app/mcp"
73
+ #
74
+ def url(value = nil)
75
+ return @url if value.nil?
76
+
77
+ @url = value
78
+ end
79
+
80
+ # Sets the command that starts a local server speaking over stdio.
81
+ # The process starts on the first request. Called with no arguments,
82
+ # returns the configured command.
83
+ #
84
+ # command "npx", "-y", "@modelcontextprotocol/server-filesystem", "."
85
+ #
86
+ def command(*argv)
87
+ return @command if argv.empty?
88
+
89
+ @command = argv.flatten
90
+ end
91
+
92
+ # Sets the transport that carries the server's JSON-RPC messages, for
93
+ # servers reached neither over Streamable HTTP nor over stdio. Pass
94
+ # the transport, a method name, or a block that returns one. Called
95
+ # with no argument, returns the configured value.
96
+ #
97
+ # transport { Tunnel.new(device) }
98
+ #
99
+ # A transport responds to four methods.
100
+ # <tt>request(message, version:, timeout:, headers:)</tt> sends a
101
+ # JSON-RPC request and returns the response as a Hash with string
102
+ # keys, yielding any notifications the server sends meanwhile.
103
+ # +timeout+ is +nil+ unless RubyLLM needs a shorter one than the
104
+ # transport's own, and +headers+ holds the tool arguments the server
105
+ # asks to receive as <tt>Mcp-Param-*</tt> HTTP headers.
106
+ # <tt>notify(message, version:)</tt> and
107
+ # <tt>cancel(notification, version:)</tt> send a notification, and
108
+ # +close+ releases the connection until the next request. Raise
109
+ # MCP::Error when the server cannot be reached. The transport handles
110
+ # its own authentication and timeouts.
111
+ def transport(value = nil, &block)
112
+ return @transport if value.nil? && block.nil?
113
+
114
+ @transport = block || value
115
+ end
116
+
117
+ # Sets the working directory for a stdio server's process.
118
+ #
119
+ # directory Rails.root
120
+ #
121
+ def directory(value = nil)
122
+ return @directory if value.nil?
123
+
124
+ @directory = value
125
+ end
126
+
127
+ # Adds environment variables for a stdio server's process. Values may
128
+ # be blocks or method names. Called with no arguments, returns them.
129
+ #
130
+ # env NODE_ENV: "production", API_KEY: -> { user.api_key }
131
+ #
132
+ def env(**variables)
133
+ return @env || {} if variables.empty?
134
+
135
+ @env = env.merge(variables)
136
+ end
137
+
138
+ # Adds an HTTP header sent with every request to the server. Pass the
139
+ # value, a method name, or a block.
140
+ #
141
+ # header "X-MCP-Toolsets", "issues,pull_requests"
142
+ # header("X-Account") { user.account_id }
143
+ #
144
+ def header(name, value = nil, &block)
145
+ @headers = headers.merge(name.to_s => block || value)
146
+ end
147
+
148
+ def headers # :nodoc:
149
+ @headers || {}
150
+ end
151
+
152
+ # Sets the bearer token sent in the +Authorization+ header. Pass the
153
+ # token, a method name, or a block. Called with no argument, returns the
154
+ # configured value.
155
+ #
156
+ # bearer_token ENV.fetch("LINEAR_API_KEY")
157
+ # bearer_token { user.linear_token }
158
+ #
159
+ def bearer_token(value = nil, &block)
160
+ return @bearer_token if value.nil? && block.nil?
161
+
162
+ @bearer_token = block || value
163
+ end
164
+
165
+ # Authorizes requests with OAuth, as the MCP authorization spec
166
+ # describes. RubyLLM discovers the server's authorization server and
167
+ # registers itself unless you pass the +client_id:+ and
168
+ # +client_secret:+ of an app you registered, which servers such as
169
+ # Slack require. Those only go to the authorization server they were
170
+ # first used with. +owner:+ names whose credentials these are, usually
171
+ # an input. +scopes:+ overrides the scopes the server asks for.
172
+ #
173
+ # oauth owner: :user
174
+ # oauth owner: :user, client_id: ENV["SLACK_CLIENT_ID"], client_secret: ENV["SLACK_CLIENT_SECRET"]
175
+ #
176
+ # Send the user to MCP#authorization_url, then pass the callback's
177
+ # parameters to MCP#authorize. Servers that require DPoP get tokens
178
+ # bound to a key that RubyLLM keeps with the credentials.
179
+ #
180
+ # +grant: :client_credentials+ connects your app as itself, with no
181
+ # user: RubyLLM requests a token when the server first asks for one
182
+ # and a new one before it expires. +private_key:+, a PEM string or an
183
+ # OpenSSL key, signs a short-lived assertion in place of
184
+ # +client_secret:+, for any app you registered.
185
+ #
186
+ # oauth grant: :client_credentials, client_id: "reports", private_key: ENV["REPORTS_PRIVATE_KEY"]
187
+ #
188
+ # +assertion:+ presents a JWT your platform issued, such as a
189
+ # Kubernetes service account token, with the JWT bearer grant
190
+ # (+grant: :jwt_bearer+), so a workload needs no credentials of its
191
+ # own. A block or method name is read for every token, as platforms
192
+ # rotate them.
193
+ #
194
+ # oauth assertion: -> { File.read("/var/run/secrets/tokens/mcp-token") }
195
+ #
196
+ # +identity_provider:+ authorizes the users who sign in to your app
197
+ # through their company's identity provider, with no consent screen:
198
+ # RubyLLM exchanges the user's ID token for a grant the server's
199
+ # authorization server accepts, as the identity provider's policy
200
+ # allows. Pass its +issuer:+, your app's +client_id:+ and
201
+ # +client_secret:+ there, and the user's +id_token:+. Values may be
202
+ # blocks or method names.
203
+ #
204
+ # oauth owner: :user, client_id: ENV["WIKI_CLIENT_ID"], client_secret: ENV["WIKI_CLIENT_SECRET"],
205
+ # identity_provider: { issuer: "https://acme.okta.com", client_id: ENV["OKTA_CLIENT_ID"],
206
+ # client_secret: ENV["OKTA_CLIENT_SECRET"], id_token: -> { user.id_token } }
207
+ def oauth(owner: nil, scopes: nil, client_id: nil, client_secret: nil, grant: nil, private_key: nil,
208
+ assertion: nil, identity_provider: nil)
209
+ raise ArgumentError, "Unknown OAuth grant: #{grant}" unless grant.nil? || OAuth::GRANTS.include?(grant)
210
+
211
+ @oauth = { owner:, scopes:, client_id:, client_secret:, grant:, private_key:, assertion:, identity_provider: }
212
+ end
213
+
214
+ def oauth_settings # :nodoc:
215
+ @oauth
216
+ end
217
+
218
+ # Sets how many seconds a request to the server may take. Defaults to
219
+ # the configured +request_timeout+.
220
+ #
221
+ # timeout 30
222
+ #
223
+ def timeout(seconds = nil)
224
+ return @timeout if seconds.nil?
225
+
226
+ @timeout = seconds
227
+ end
228
+
229
+ # Declares named inputs. Instances take them as keywords and read
230
+ # them as methods, so blocks such as a +bearer_token+ can use them.
231
+ # Called with no arguments, returns the declared names.
232
+ #
233
+ # inputs :user
234
+ #
235
+ def inputs(*names)
236
+ return @input_names || [] if names.empty?
237
+
238
+ @input_names = names.flatten.map(&:to_sym)
239
+ @input_names.each { |input| define_method(input) { @inputs[input] } }
240
+ end
241
+
242
+ # Limits the tools the model sees to the named server tools.
243
+ #
244
+ # only :search_issues, :get_issue
245
+ #
246
+ def only(*names)
247
+ return @only if names.empty?
248
+
249
+ @only = names.flatten.map(&:to_s)
250
+ end
251
+
252
+ # Hides the named server tools from the model.
253
+ #
254
+ # except :delete_repository
255
+ #
256
+ def except(*names)
257
+ return @except || [] if names.empty?
258
+
259
+ @except = names.flatten.map(&:to_s)
260
+ end
261
+
262
+ # Prefixes the names of the server's tools, so tools from servers that
263
+ # share names, such as two servers with a +search+ tool, can join one
264
+ # chat. Tools renamed with ::tool keep the name you gave them.
265
+ #
266
+ # prefix :github # search_issues becomes github_search_issues
267
+ #
268
+ def prefix(value = nil)
269
+ return @prefix if value.nil?
270
+
271
+ @prefix = value.to_s
272
+ end
273
+
274
+ # Shapes a server tool, or adds one of your own.
275
+ #
276
+ # Given a server tool's name, +as:+ renames it, +description:+
277
+ # rewrites what the model reads, and +fixed_arguments:+ removes
278
+ # arguments from the model's view and always sends your values, which
279
+ # may be lambdas. +wrap:+ names a method that receives
280
+ # the server's Result and the call's arguments and returns what the
281
+ # model sees:
282
+ #
283
+ # tool :search_files, as: :drive_search, description: "Search the user's Drive"
284
+ # tool :search_issues, fixed_arguments: { owner: "crmne", repo: "ruby_llm" }
285
+ # tool :read_file, wrap: :extract_text
286
+ #
287
+ # Given a Tool class, adds it next to the server's tools. The tool is
288
+ # created with this MCP when its +initialize+ takes an argument, so it
289
+ # can call the server:
290
+ #
291
+ # tool SearchWithPreviews
292
+ #
293
+ def tool(tool, as: nil, description: nil, fixed_arguments: nil, wrap: nil)
294
+ @tool_declarations = tool_declarations.dup
295
+ @tool_declarations << if tool.is_a?(Class)
296
+ tool
297
+ else
298
+ [tool.to_s, { as:, description:, fixed_arguments:, wrap: }.compact]
299
+ end
300
+ end
301
+
302
+ def tool_declarations # :nodoc:
303
+ @tool_declarations || []
304
+ end
305
+
306
+ # Pauses the named server tools for approval before they run, using
307
+ # the flow of Tool.requires_approval. Without names, every tool needs
308
+ # approval. +if:+ takes a Tool predicate, or a lambda that receives the
309
+ # tool:
310
+ #
311
+ # requires_approval :create_issue, :merge_pull_request
312
+ # requires_approval if: :destructive?
313
+ #
314
+ def requires_approval(*names, **options)
315
+ unknown = options.keys - [:if]
316
+ raise ArgumentError, "Unknown requires_approval options: #{unknown.join(', ')}" if unknown.any?
317
+
318
+ @approvals = approvals + [[names.flatten.map(&:to_s), options[:if]]]
319
+ end
320
+
321
+ def approvals # :nodoc:
322
+ @approvals || []
323
+ end
324
+
325
+ # Keeps the named server tools out of the model's context until the
326
+ # provider's tool search loads them. Without names, every tool of the
327
+ # server is deferred. Chat#with_mcp with +defer:+ overrides it per
328
+ # chat.
329
+ #
330
+ # defer :search_code, :list_workflows
331
+ # defer
332
+ #
333
+ def defer(*names)
334
+ @deferrals = deferrals + [names.flatten.map(&:to_s)]
335
+ end
336
+
337
+ def deferrals # :nodoc:
338
+ @deferrals || []
339
+ end
340
+
341
+ # Registers a callback for the progress the server reports while it
342
+ # works on a request. Pass a method name or a block; either runs on
343
+ # the MCP instance with a Progress. In a chat, the progress of a tool
344
+ # call also reaches Chat#after_tool_progress.
345
+ #
346
+ # after_progress :broadcast_progress
347
+ # after_progress { |progress| puts progress.message }
348
+ #
349
+ def after_progress(method = nil, &block)
350
+ add_callback(:after_progress, method, block)
351
+ end
352
+
353
+ # Registers a callback for the changes the server announces. Pass a
354
+ # method name or a block; either runs on the MCP instance with what
355
+ # changed: +:tools+, +:prompts+, or +:resources+ when the server's
356
+ # list of them changes, the MCP::Resource whose content changed, or
357
+ # the MCP::Task whose status changed. RubyLLM has already forgotten
358
+ # the old tools when +:tools+ arrives.
359
+ #
360
+ # after_change :refresh
361
+ # after_change { |change| Rails.cache.delete("handbook/tools") if change == :tools }
362
+ #
363
+ # Servers that predate 2026-07-28 announce changes as they answer a
364
+ # request; the callback runs once the request is answered. Newer
365
+ # servers announce them only while the MCP listens; see #listen.
366
+ # Changes made while a listener reconnects are lost, so once it is
367
+ # back, the callback runs for everything it listens to.
368
+ def after_change(method = nil, &block)
369
+ add_callback(:after_change, method, block)
370
+ end
371
+
372
+ # Sets the kinds of requests for input the server may send: +:form+,
373
+ # +:url+, or both, the default. Pass +false+ when your app cannot
374
+ # show them to anyone, so a call never waits on an answer that will
375
+ # not come. RubyLLM declines requests of other kinds. Called with no
376
+ # arguments, returns the accepted kinds.
377
+ #
378
+ # input_requests false
379
+ # input_requests :url
380
+ #
381
+ def input_requests(*kinds)
382
+ return @input_requests || INPUT_REQUESTS if kinds.empty?
383
+
384
+ kinds = (kinds.flatten - [false, nil]).map(&:to_sym)
385
+ unknown = kinds - INPUT_REQUESTS
386
+ raise ArgumentError, "Unknown input requests: #{unknown.join(', ')}" if unknown.any?
387
+
388
+ @input_requests = kinds
389
+ end
390
+
391
+ # Registers a callback for the server's requests for input from the
392
+ # user. Pass a method name or a block; either runs on the MCP instance
393
+ # with an MCP::InputRequest to answer or decline. In a chat, a request
394
+ # no callback answers pauses the tool call; see Chat#pending_inputs.
395
+ #
396
+ # before_input_request :ask_operator
397
+ # before_input_request { |request| request.answer(environment: "staging") }
398
+ #
399
+ def before_input_request(method = nil, &block)
400
+ add_callback(:before_input_request, method, block)
401
+ end
402
+
403
+ # Declares an extension to the protocol that your app supports, so
404
+ # servers can use it. +settings+ belong to the extension and go to
405
+ # the server as written. Extensions are named with a vendor prefix.
406
+ #
407
+ # extension "com.example/audit", level: "full"
408
+ #
409
+ # RubyLLM implements two extensions you declare by name. With
410
+ # +:apps+, MCP Apps, tools come with a UI your app renders next to
411
+ # their results, and tools that only a UI may call stay out of chats;
412
+ # see MCP::Tool#visibility. With +:tasks+, a server may run a long
413
+ # tool call in the background; see MCP::Task.
414
+ #
415
+ # extension :apps
416
+ # extension :tasks
417
+ #
418
+ # RubyLLM declares extensions in the capabilities of every request,
419
+ # and when it connects to a server that predates 2026-07-28.
420
+ #
421
+ # Raises ArgumentError for a name without a vendor prefix, or a
422
+ # Symbol RubyLLM does not know.
423
+ def extension(name, **settings)
424
+ name, defaults = extension_identifier(name)
425
+ @extensions = extensions.merge(name => defaults.merge(settings.transform_keys(&:to_s)))
426
+ end
427
+
428
+ def extensions # :nodoc:
429
+ @extensions || {}
430
+ end
431
+
432
+ # Asks the server for the log messages it writes while it works on a
433
+ # request, at +level+ and above, and writes them to the RubyLLM
434
+ # logger. The levels are the protocol's, from +:debug+ through
435
+ # +:info+, +:notice+, +:warning+, +:error+, +:critical+, and +:alert+
436
+ # to +:emergency+. Without a level, servers send no log messages.
437
+ # Called with no argument, returns the level.
438
+ #
439
+ # log_level :warning
440
+ #
441
+ # Raises ArgumentError for a level the protocol does not define.
442
+ def log_level(level = nil)
443
+ return @log_level if level.nil?
444
+ raise ArgumentError, "Unknown MCP log level: #{level}" unless LOG_LEVELS.key?(level.to_sym)
445
+
446
+ @log_level = level.to_sym
447
+ end
448
+
449
+ def callbacks(name) # :nodoc:
450
+ (@callbacks || {}).fetch(name, [])
451
+ end
452
+
453
+ def default_name # :nodoc:
454
+ return @default_name if @default_name
455
+ return Support::Utils.underscore(name.split('::').last) if name
456
+
457
+ default_name_for(url:, command:) if url || command
458
+ end
459
+
460
+ # Builds an anonymous MCP class from keywords, as RubyLLM.mcp does.
461
+ def define(name: nil, headers: {}, env: {}, oauth: nil, extensions: nil, **settings) # :nodoc:
462
+ check_inline_settings(name, settings)
463
+
464
+ Class.new(self) do
465
+ settings.each { |setting, value| public_send(setting, value) unless value.nil? }
466
+ headers.each { |header_name, value| header(header_name, value) }
467
+ Array(extensions).each { |extension_name, options| extension(extension_name, **options.to_h) }
468
+ env(**env)
469
+ oauth(**(oauth == true ? {} : oauth)) if oauth
470
+ self.default_name = name if name
471
+ end
472
+ end
473
+
474
+ private
475
+
476
+ def check_inline_settings(name, settings)
477
+ unknown = settings.keys - INLINE_SETTINGS
478
+ raise ArgumentError, "Unknown MCP settings: #{unknown.join(', ')}" if unknown.any?
479
+ raise ArgumentError, 'An MCP with a transport needs a name' if settings[:transport] && name.nil?
480
+ end
481
+
482
+ def extension_identifier(name)
483
+ return EXTENSIONS.fetch(name) { raise ArgumentError, "Unknown MCP extension: #{name}" } if name.is_a?(Symbol)
484
+ return [name.to_s, {}] if name.to_s.include?('/')
485
+
486
+ raise ArgumentError, "MCP extensions are named with a vendor prefix, such as com.example/#{name}"
487
+ end
488
+
489
+ def add_callback(name, method, block)
490
+ raise ArgumentError, "#{name} takes a method name or a block" unless method.nil? ^ block.nil?
491
+
492
+ @callbacks = (@callbacks || {}).merge(name => callbacks(name) + [method || block])
493
+ end
494
+
495
+ def default_name_for(url:, command:)
496
+ return File.basename(Array(command).first.to_s) unless url
497
+
498
+ labels = URI(url).host.split('.')[0...-1] - %w[mcp api www]
499
+ labels.join('_')
500
+ end
501
+ end
502
+
503
+ # Creates an MCP. Keywords are the values of the declared ::inputs.
504
+ # Pass a Context as +context:+ to connect with its configuration: its
505
+ # +faraday_adapter+, +http_proxy+, and +request_timeout+ apply to the
506
+ # server's requests and to its OAuth requests.
507
+ #
508
+ # Linear.new(user: current_user)
509
+ # Linear.new(user: current_user, context: RubyLLM.context { |config| config.http_proxy = proxy })
510
+ #
511
+ # Raises ArgumentError for keywords that are not declared inputs.
512
+ def initialize(context: nil, **inputs)
513
+ unknown = inputs.keys - self.class.inputs
514
+ raise ArgumentError, "Unknown MCP inputs: #{unknown.join(', ')}" if unknown.any?
515
+
516
+ @context = context
517
+ @inputs = inputs
518
+ end
519
+
520
+ # Returns the name that identifies this MCP in a chat, derived from the
521
+ # class name.
522
+ #
523
+ # GoogleDrive.new.name # => "google_drive"
524
+ #
525
+ def name
526
+ self.class.default_name
527
+ end
528
+
529
+ # Returns the server's tools, shaped by ::only, ::except, and ::tool,
530
+ # followed by the Tool classes added with ::tool. The server's list is
531
+ # fetched once, and again after the server says it changed or answers
532
+ # a call with an error because the tool is gone.
533
+ #
534
+ # The list includes the tools of an MCP App that only its UI may call,
535
+ # whose MCP::Tool#visibility leaves out +:model+. Chats never offer
536
+ # those to the model.
537
+ #
538
+ # Raises ConfigurationError when a declaration names a tool the server
539
+ # does not offer.
540
+ def tools
541
+ @tools || remember(:@tools) do
542
+ definitions = server_tools
543
+ check_declared_tools(definitions)
544
+ definitions.filter_map { |definition| shape(definition) } + added_tools
545
+ end
546
+ end
547
+
548
+ # Calls the server tool +name+ with +arguments+ and returns an
549
+ # MCP::Result. Every server tool is also a method:
550
+ #
551
+ # linear.call(:list_issues, query: "bug")
552
+ # linear.list_issues(query: "bug")
553
+ #
554
+ # When the server runs the call as a task, waits for it the way
555
+ # MCP::Task#wait does, and cancels it if waiting fails.
556
+ #
557
+ # Raises MCP::Error when the server answers with a protocol error. A
558
+ # tool that fails returns a Result whose #error? is +true+.
559
+ def call(name, **arguments)
560
+ outcome = call_tool({ name: name.to_s, arguments: })
561
+ Result.new(outcome.is_a?(Task) ? finish(outcome) : outcome, ui_uri_of(name))
562
+ end
563
+
564
+ # Returns the resources the server lists, as MCP::Resource objects
565
+ # whose content is read when you first ask for it.
566
+ def resources
567
+ client.list('resources/list', 'resources').map { |data| Resource.new(self, data) }
568
+ end
569
+
570
+ # Reads the resource at +uri+ and returns an MCP::Resource. Given a
571
+ # template from #resource_templates, +variables+ fill it in.
572
+ #
573
+ # files.resource("file:///project/README.md")
574
+ # files.resource("file:///{path}", path: "Gemfile")
575
+ #
576
+ def resource(uri, **variables)
577
+ uri = ResourceTemplate.expand(uri, variables) unless variables.empty?
578
+ contents = request('resources/read', { uri: }).fetch('contents', [])
579
+ data = contents.find { |content| content['uri'] == uri } || contents.first
580
+ raise Error, "#{name} returned no content for #{uri}" unless data
581
+
582
+ Resource.new(self, data)
583
+ end
584
+
585
+ # Returns the server's resource templates as MCP::ResourceTemplate
586
+ # objects.
587
+ def resource_templates
588
+ client.list('resources/templates/list', 'resourceTemplates').map { |data| ResourceTemplate.new(self, data) }
589
+ end
590
+
591
+ # Returns the prompts the server offers, as MCP::Prompt objects.
592
+ def prompts
593
+ client.list('prompts/list', 'prompts').map { |data| Prompt.new(self, data) }
594
+ end
595
+
596
+ # Fills in the server prompt +name+ with +arguments+ and returns an
597
+ # MCP::Prompt with its messages, ready for Chat#ask.
598
+ #
599
+ # chat.ask github.prompt(:code_review, code: diff)
600
+ #
601
+ def prompt(name, **arguments)
602
+ result = request('prompts/get', { name: name.to_s, arguments: arguments.transform_values(&:to_s) })
603
+ messages = result.fetch('messages', []).map do |message|
604
+ content, attachments = Content.read([message['content']])
605
+ Message.new(role: message['role'].to_sym, content:, attachments:)
606
+ end
607
+ Prompt.new(self, { 'name' => name.to_s, 'description' => result['description'] }, messages:)
608
+ end
609
+
610
+ # Asks the server to complete the first of +values+ for +reference+,
611
+ # with the rest as context.
612
+ def suggest(reference, values) # :nodoc:
613
+ (argument, value), *filled = values.to_a
614
+ raise ArgumentError, 'Pass the value to complete as a keyword' unless argument
615
+
616
+ params = { ref: reference, argument: { name: argument.to_s, value: value.to_s } }
617
+ params[:context] = { arguments: filled.to_h { |key, filler| [key.to_s, filler.to_s] } } if filled.any?
618
+ client.request('completion/complete', params).dig('completion', 'values') || []
619
+ end
620
+
621
+ # Returns whether +tool+, one of this MCP's tools, needs approval
622
+ # according to ::requires_approval.
623
+ def requires_approval?(tool) # :nodoc:
624
+ self.class.approvals.any? do |names, condition|
625
+ next false unless names.empty? || names.include?(tool.server_name)
626
+
627
+ condition.nil? || (condition.is_a?(Proc) ? instance_exec(tool, &condition) : tool.public_send(condition))
628
+ end
629
+ end
630
+
631
+ # Returns whether +tool+, one of this MCP's tools, is deferred
632
+ # according to ::defer or a Tool class's own Tool.defer.
633
+ def defers?(tool) # :nodoc:
634
+ return true if tool.class.deferred?
635
+
636
+ self.class.deferrals.any? do |names|
637
+ names.empty? || (tool.respond_to?(:server_name) && names.include?(tool.server_name))
638
+ end
639
+ end
640
+
641
+ # Runs +tool+, one of this MCP's tools, with the model's +arguments+.
642
+ def run(tool, arguments, input: nil) # :nodoc:
643
+ arguments = arguments.transform_keys(&:to_sym)
644
+ fixed = tool.fixed_arguments.transform_values { |value| value.is_a?(Proc) ? instance_exec(&value) : value }
645
+ params = { name: tool.server_name, arguments: arguments.merge(fixed) }
646
+ outcome = call_tool(params, input:)
647
+ return outcome if outcome.is_a?(Task)
648
+
649
+ result = Result.new(outcome, tool.ui_uri)
650
+ return { error: result.text } if result.error?
651
+
652
+ tool.wrap ? apply(tool.wrap, result, **arguments) : result
653
+ end
654
+
655
+ # Checks on the task +id+ once and returns its state, reporting what it
656
+ # is doing as progress.
657
+ def poll_task(id) # :nodoc:
658
+ data = client.request('tasks/get', { taskId: id })
659
+ message = data['statusMessage']
660
+ if message && %w[working input_required].include?(data['status'])
661
+ progress_listeners.each { |listener| listener.call(Progress.new(message:)) }
662
+ end
663
+ data
664
+ end
665
+
666
+ # Asks the server to cancel the task +id+.
667
+ def cancel_task(id) # :nodoc:
668
+ client.request('tasks/cancel', { taskId: id })
669
+ end
670
+
671
+ # Checks on +task+ until it is done, as MCP::Task#wait describes.
672
+ def await_task(task, timeout: nil, interval: nil) # :nodoc:
673
+ limit = timeout || self.class.timeout || config.request_timeout
674
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + limit
675
+ until task.done?
676
+ ask_for_task_input(task) if task.status == :input_required
677
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
678
+ raise Error, "Task #{task.id} did not finish in #{limit} seconds" unless remaining.positive?
679
+
680
+ Support::Cancellation.pause([interval || poll_interval(task), remaining].min)
681
+ task.refresh
682
+ end
683
+ raise task.error unless task.completed?
684
+
685
+ task
686
+ end
687
+
688
+ # Returns the instructions the server gives for using it, or +nil+.
689
+ def instructions
690
+ client.server['instructions']
691
+ end
692
+
693
+ # Returns the version the server reports for itself, or +nil+.
694
+ def version
695
+ server_info['version']
696
+ end
697
+
698
+ # Returns whether the owner has authorized this server. Only for
699
+ # servers declared with ::oauth.
700
+ def authorized?
701
+ oauth.authorized?
702
+ end
703
+
704
+ # Returns the URL to send the user to so they can authorize this
705
+ # server. The authorization server redirects back to +redirect_uri+,
706
+ # whose parameters go to #authorize.
707
+ #
708
+ # redirect_to linear.authorization_url(redirect_uri: mcp_callback_url), allow_other_host: true
709
+ #
710
+ def authorization_url(redirect_uri:)
711
+ oauth.authorization_url(redirect_uri:, challenge: @challenge || challenge)
712
+ end
713
+
714
+ # Completes an authorization with the parameters of the callback
715
+ # request, such as a controller's +params+. Returns +self+.
716
+ #
717
+ # Linear.new(user: current_user).authorize(params)
718
+ #
719
+ # Raises MCP::Error when the callback does not match the authorization
720
+ # that #authorization_url started.
721
+ def authorize(params)
722
+ oauth.authorize(params)
723
+ self
724
+ end
725
+
726
+ # Forgets the owner's credentials for this server. Returns +self+.
727
+ def deauthorize
728
+ oauth.deauthorize
729
+ self
730
+ end
731
+
732
+ # Listens for the server's changes in a background thread until
733
+ # #close, so ::after_change callbacks run as changes happen and #tools
734
+ # follows the server's list. Pass +resources+, as URIs or
735
+ # MCP::Resource objects, to hear when their content changes, and
736
+ # +tasks+, as MCP::Task objects or their IDs, to hear when their status
737
+ # changes; a later call replaces them. Returns +self+ once the server
738
+ # confirms.
739
+ #
740
+ # handbook = Handbook.new.listen(resources: ["handbook://policies"])
741
+ # reports.listen(tasks: chat.pending_tasks)
742
+ #
743
+ # Without +resources+ or +tasks+, does nothing for a server that
744
+ # announces no changes. Raises MCP::Error when the server cannot be
745
+ # reached or does not send updates for the resources or tasks.
746
+ def listen(resources: [], tasks: [])
747
+ uris = resources.map { |resource| resource.respond_to?(:uri) ? resource.uri : resource.to_s }
748
+ ids = tasks.map { |task| task.respond_to?(:id) ? task.id : task.to_s }
749
+ watched = listener.start(listened_changes(uris, ids)) || {}
750
+ missing = (uris - Array(watched['resourceSubscriptions'])) + (ids - Array(watched['taskIds']))
751
+ return self if missing.empty?
752
+
753
+ listener.stop
754
+ raise Error, "#{name} does not send updates for #{missing.join(', ')}"
755
+ end
756
+
757
+ # Closes the connection, stopping a stdio server's process and ending
758
+ # the session of a server that keeps one, and stops listening. The next
759
+ # request reconnects.
760
+ def close
761
+ @listener&.stop
762
+ @client&.close
763
+ end
764
+
765
+ private
766
+
767
+ def method_missing(name, *arguments, **keywords, &)
768
+ return super unless arguments.empty? && server_tool?(name)
769
+
770
+ call(name, **keywords)
771
+ end
772
+
773
+ def respond_to_missing?(name, include_private = nil)
774
+ (@server_tools && server_tool?(name)) || super
775
+ end
776
+
777
+ def request(method, params, input: nil)
778
+ result = input ? send_answers(method, params, input) : send_request(method, params)
779
+ INPUT_ROUNDS.times do
780
+ return result unless result['resultType'] == 'input_required'
781
+
782
+ input = { 'requests' => requests_for_input(result), 'request_state' => result['requestState'] }
783
+ raise InputRequiredError.new(name, input) unless input['requests'].all?(&:answered?)
784
+
785
+ result = send_answers(method, params, input)
786
+ end
787
+ raise Error, "#{name} kept asking for input"
788
+ end
789
+
790
+ def send_answers(method, params, input)
791
+ responses = responses(input['requests'])
792
+ send_request(method, params.merge({ inputResponses: responses, requestState: input['request_state'] }.compact))
793
+ end
794
+
795
+ def responses(requests)
796
+ requests.to_h do |request|
797
+ request = InputRequest.from_h(request) if request.is_a?(Hash)
798
+ [request.key, request.response]
799
+ end
800
+ end
801
+
802
+ def call_tool(params, input: nil)
803
+ return check_task(Task.load(self, input), input['requests']) if input&.key?('task')
804
+
805
+ data = request_tool(params, input:)
806
+ data['resultType'] == 'task' ? Task.new(self, data) : data
807
+ end
808
+
809
+ def request_tool(params, input:)
810
+ request('tools/call', params, input:)
811
+ rescue Error => e
812
+ forget_tools if UNKNOWN_TOOL_ERRORS.include?(e.code)
813
+ raise
814
+ end
815
+
816
+ def check_task(task, answered_requests)
817
+ update_task(task, answered_requests) if answered_requests
818
+ task.refresh
819
+ return task.data['result'] if task.completed?
820
+ raise task.error if task.done?
821
+
822
+ ask_for_task_input(task) if task.status == :input_required
823
+ task
824
+ end
825
+
826
+ def ask_for_task_input(task)
827
+ requests = requests_for_input('inputRequests' => task.data.fetch('inputRequests', {}).except(*task.answered))
828
+ return if requests.empty?
829
+ raise InputRequiredError.new(name, task.to_h.merge('requests' => requests)) unless requests.all?(&:answered?)
830
+
831
+ update_task(task, requests)
832
+ end
833
+
834
+ def update_task(task, requests)
835
+ responses = responses(requests)
836
+ client.request('tasks/update', { taskId: task.id, inputResponses: responses })
837
+ task.record_answers(responses.keys)
838
+ end
839
+
840
+ def poll_interval(task)
841
+ task.poll_interval || POLL_INTERVAL
842
+ end
843
+
844
+ def finish(task)
845
+ task.wait.data['result']
846
+ rescue StandardError
847
+ begin
848
+ task.cancel
849
+ rescue Error, Faraday::Error => e
850
+ RubyLLM.logger.debug { "#{name} could not cancel task #{task.id}: #{e.message}" }
851
+ end
852
+ raise
853
+ end
854
+
855
+ def requests_for_input(result)
856
+ result.fetch('inputRequests', {}).filter_map do |key, request|
857
+ next unless request['method'] == 'elicitation/create'
858
+
859
+ InputRequest.new(key, request['params'] || {}).tap do |input_request|
860
+ next input_request.decline unless accepts?(input_request)
861
+
862
+ self.class.callbacks(:before_input_request).each do |callback|
863
+ apply(callback, input_request) unless input_request.answered?
864
+ end
865
+ end
866
+ end
867
+ end
868
+
869
+ def accepts?(input_request)
870
+ self.class.input_requests.include?(input_request.url? ? :url : :form)
871
+ end
872
+
873
+ def send_request(method, params)
874
+ headers = method == 'tools/call' ? mirrored_headers(params) : {}
875
+ listeners = progress_listeners
876
+ level = self.class.log_level
877
+ return client.request(method, params, headers:) if listeners.empty? && level.nil?
878
+
879
+ token = SecureRandom.uuid unless listeners.empty?
880
+ meta = { progressToken: token, 'io.modelcontextprotocol/logLevel' => level&.to_s }.compact
881
+ client.request(method, params.merge(_meta: meta), headers:) do |notification|
882
+ data = notification['params'] || {}
883
+ case notification['method']
884
+ when 'notifications/progress' then report_progress(data, token, listeners)
885
+ when 'notifications/message' then log(data, level)
886
+ end
887
+ end
888
+ end
889
+
890
+ def report_progress(data, token, listeners)
891
+ return unless token && data['progressToken'] == token
892
+
893
+ progress = Progress.new(value: data['progress'], total: data['total'], message: data['message'])
894
+ listeners.each { |listener| listener.call(progress) }
895
+ end
896
+
897
+ def log(data, minimum)
898
+ level = data['level'].to_s.to_sym
899
+ return unless minimum && LOG_LEVELS.key?(level) && LOG_LEVELS.keys.index(level) >= LOG_LEVELS.keys.index(minimum)
900
+
901
+ text = data['data'].is_a?(String) ? data['data'] : JSON.generate(data['data'])
902
+ RubyLLM.logger.add(LOG_LEVELS[level], "#{name}#{" (#{data['logger']})" if data['logger']}: #{text}")
903
+ end
904
+
905
+ def progress_listeners
906
+ listeners = self.class.callbacks(:after_progress).map { |callback| ->(progress) { apply(callback, progress) } }
907
+ [*listeners, Support::ProgressReporter.listener].compact
908
+ end
909
+
910
+ def mirrored_headers(params)
911
+ definition = server_tool(params[:name])
912
+ definition ? ParamHeaders.for(definition, params[:arguments]) : {}
913
+ end
914
+
915
+ def ui_uri_of(name)
916
+ definition = server_tool(name)
917
+ Apps.uri(definition['_meta'] || {}) if definition
918
+ end
919
+
920
+ def shape(definition)
921
+ name = definition['name']
922
+ only = self.class.only
923
+ return if (only && !only.include?(name)) || self.class.except.include?(name)
924
+
925
+ options = self.class.tool_declarations.select { |declaration| declaration.is_a?(Array) && declaration[0] == name }
926
+ .map(&:last).reduce({}, :merge)
927
+ Tool.new(self, definition, prefix: self.class.prefix, **options)
928
+ end
929
+
930
+ def added_tools
931
+ self.class.tool_declarations.grep(Class).map do |tool|
932
+ tool.instance_method(:initialize).arity.zero? ? tool.new : tool.new(self)
933
+ end
934
+ end
935
+
936
+ def check_declared_tools(definitions)
937
+ declared = Array(self.class.only) + self.class.approvals.flat_map(&:first) +
938
+ self.class.tool_declarations.grep(Array).map(&:first)
939
+ missing = declared.uniq - definitions.map { |definition| definition['name'] }
940
+ return if missing.empty?
941
+
942
+ raise ConfigurationError, "#{name} declares #{missing.join(', ')}, which the server does not offer"
943
+ end
944
+
945
+ def apply(callable, *, **keywords)
946
+ callable.is_a?(Proc) ? instance_exec(*, **keywords, &callable) : send(callable, *, **keywords)
947
+ end
948
+
949
+ def server_tool?(name)
950
+ !server_tool(name).nil?
951
+ end
952
+
953
+ def server_tool(name)
954
+ server_tools.find { |definition| definition['name'] == name.to_s }
955
+ end
956
+
957
+ def server_tools
958
+ @server_tools || remember(:@server_tools) do
959
+ client.list('tools/list', 'tools').select { |definition| ParamHeaders.valid?(definition) }
960
+ end
961
+ end
962
+
963
+ def remember(variable)
964
+ changes = @tool_changes
965
+ value = yield
966
+ instance_variable_set(variable, value) if changes == @tool_changes
967
+ value
968
+ end
969
+
970
+ def changed(notification)
971
+ case notification['method']
972
+ when Client::ACKNOWLEDGED then forget_tools
973
+ when 'notifications/tools/list_changed'
974
+ forget_tools
975
+ announce(:tools)
976
+ when 'notifications/prompts/list_changed' then announce(:prompts)
977
+ when 'notifications/resources/list_changed' then announce(:resources)
978
+ when 'notifications/resources/updated'
979
+ announce(Resource.new(self, 'uri' => notification.dig('params', 'uri')))
980
+ when 'notifications/tasks' then announce(Task.new(self, notification['params'].except('_meta')))
981
+ end
982
+ end
983
+
984
+ def forget_tools
985
+ @tool_changes = @tool_changes.to_i + 1
986
+ @tools = @server_tools = nil
987
+ end
988
+
989
+ def announce(change)
990
+ self.class.callbacks(:after_change).each { |callback| apply(callback, change) }
991
+ end
992
+
993
+ def listener
994
+ @listener ||= Listener.new(client, name:, timeout: self.class.timeout || config.request_timeout,
995
+ resumed: method(:caught_up)) { |notification| changed(notification) }
996
+ end
997
+
998
+ def caught_up(listened)
999
+ %i[tools prompts resources].each { |list| announce(list) if listened["#{list}ListChanged"] }
1000
+ Array(listened['resourceSubscriptions']).each { |uri| announce(Resource.new(self, 'uri' => uri)) }
1001
+ Array(listened['taskIds']).each { |id| announce(Task.new(self, poll_task(id))) }
1002
+ end
1003
+
1004
+ def listened_changes(uris, ids)
1005
+ capabilities = client.server['capabilities'] || {}
1006
+ changes = %w[tools prompts resources].each_with_object({}) do |list, listened|
1007
+ listened[:"#{list}ListChanged"] = true if capabilities.dig(list, 'listChanged')
1008
+ end
1009
+ changes[:resourceSubscriptions] = uris unless uris.empty?
1010
+ changes[:taskIds] = ids unless ids.empty?
1011
+ changes
1012
+ end
1013
+
1014
+ def server_info
1015
+ server = client.server
1016
+ server['serverInfo'] || server.dig('_meta', 'io.modelcontextprotocol/serverInfo') || {}
1017
+ end
1018
+
1019
+ def client
1020
+ @client ||= Client.new(transport, capabilities:) { |notification| changed(notification) }
1021
+ end
1022
+
1023
+ def capabilities
1024
+ kinds = self.class.input_requests
1025
+ extensions = OAuth.extensions(self.class.oauth_settings).merge(self.class.extensions)
1026
+ capabilities = kinds.empty? ? {} : { elicitation: kinds.to_h { |kind| [kind, {}] } }
1027
+ extensions.empty? ? capabilities : capabilities.merge(extensions:)
1028
+ end
1029
+
1030
+ def transport
1031
+ settings = self.class
1032
+ if settings.transport
1033
+ resolve(settings.transport)
1034
+ elsif settings.url
1035
+ HTTP.new(resolve(settings.url), headers: method(:request_headers), timeout: settings.timeout,
1036
+ unauthorized: method(:unauthorized), responded: method(:responded), config:)
1037
+ elsif settings.command
1038
+ Stdio.new(settings.command.map { |part| resolve(part) },
1039
+ env: settings.env.transform_values { |value| resolve(value) },
1040
+ directory: resolve(settings.directory), timeout: settings.timeout, config:)
1041
+ else
1042
+ raise ConfigurationError, "#{settings.name || 'MCP'} needs a url, a command, or a transport"
1043
+ end
1044
+ end
1045
+
1046
+ def request_headers(verb)
1047
+ headers = self.class.headers.transform_values { |value| resolve(value) }
1048
+ return headers.merge(oauth.authorization_headers(verb)) if self.class.oauth_settings
1049
+
1050
+ token = resolve(self.class.bearer_token)
1051
+ token ? headers.merge('Authorization' => "Bearer #{token}") : headers
1052
+ end
1053
+
1054
+ def oauth
1055
+ settings = self.class.oauth_settings or raise ConfigurationError, "#{name} does not use OAuth"
1056
+ owner = resolve(settings[:owner])
1057
+ raise ArgumentError, "#{name} needs an owner for OAuth credentials" if settings[:owner] && owner.nil?
1058
+
1059
+ @oauth ||= OAuth.new(resolve(self.class.url), owner:, resolve: method(:resolve), config:,
1060
+ **settings.except(:owner))
1061
+ end
1062
+
1063
+ def config
1064
+ @context&.config || RubyLLM.config
1065
+ end
1066
+
1067
+ def unauthorized(headers, status, recovered)
1068
+ @challenge = OAuth.challenge(headers['www-authenticate'] || headers['WWW-Authenticate'])
1069
+ return unless status == 401 && self.class.oauth_settings
1070
+
1071
+ oauth.recover(@challenge, nonce: dpop_nonce(headers), recovered:)
1072
+ end
1073
+
1074
+ def responded(headers)
1075
+ oauth.remember_nonce(dpop_nonce(headers)) if self.class.oauth_settings
1076
+ end
1077
+
1078
+ def dpop_nonce(headers)
1079
+ headers['dpop-nonce'] || headers['DPoP-Nonce']
1080
+ end
1081
+
1082
+ def challenge
1083
+ client.server
1084
+ nil
1085
+ rescue UnauthorizedError
1086
+ @challenge
1087
+ end
1088
+
1089
+ def resolve(value)
1090
+ case value
1091
+ when Proc then instance_exec(&value)
1092
+ when Symbol then send(value)
1093
+ else value
1094
+ end
1095
+ end
1096
+
1097
+ def inspect_attributes # :nodoc:
1098
+ { name:, url: self.class.url, command: self.class.command&.join(' ') }
1099
+ end
1100
+ end
1101
+ end