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,653 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'openssl'
5
+ require 'strscan'
6
+
7
+ module RubyLLM
8
+ class MCP
9
+ # OAuth for one MCP server and one owner, following the 2026-07-28
10
+ # authorization spec: protected resource metadata, authorization server
11
+ # discovery, client ID metadata documents or dynamic registration when no
12
+ # client is configured, PKCE, resource indicators, issuer checks, and
13
+ # token refresh. Credentials live in the configured
14
+ # +mcp_credential_store+, keyed by owner and server.
15
+ #
16
+ # Two grants need no user to authorize: the client credentials grant
17
+ # of the OAuth Client Credentials extension, and the JWT bearer grant
18
+ # (RFC 7523 section 2.1). The latter presents a token the workload's
19
+ # platform issued (Workload Identity Federation) or an identity
20
+ # assertion grant the user's identity provider issued for their ID
21
+ # token (Enterprise-Managed Authorization). As the extensions' flows
22
+ # describe, a token is requested once the server rejects a request
23
+ # without one, and again before it expires. Pre-registered clients
24
+ # authenticate with their secret or with a private_key_jwt assertion
25
+ # (RFC 7523 section 2.2) addressed to the authorization server's
26
+ # issuer.
27
+ #
28
+ # A server that requires DPoP-bound tokens (RFC 9449) gets them: new
29
+ # tokens are bound to a key kept with them in the credentials, and
30
+ # every token request and server request carries a fresh proof with
31
+ # the nonce the server last supplied.
32
+ class OAuth # :nodoc:
33
+ PENDING_FOR = 600
34
+ REFRESH_EARLY = 60
35
+ ASSERTION_FOR = 60
36
+ GRANTS = %i[authorization_code client_credentials jwt_bearer].freeze
37
+ JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
38
+ TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange'
39
+ ID_JAG = 'urn:ietf:params:oauth:token-type:id-jag'
40
+ ID_TOKEN = 'urn:ietf:params:oauth:token-type:id_token'
41
+ CLIENT_ASSERTION = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
42
+ CLIENT_CREDENTIALS = 'io.modelcontextprotocol/oauth-client-credentials'
43
+ ENTERPRISE_MANAGED = 'io.modelcontextprotocol/enterprise-managed-authorization'
44
+ SERVER_FIELDS = %w[
45
+ issuer token_endpoint token_endpoint_auth_methods_supported token_endpoint_auth_signing_alg_values_supported
46
+ authorization_response_iss_parameter_supported
47
+ ].freeze
48
+ CLIENT_FIELDS = %w[client_id client_secret issuer server redirect_uri dpop_key].freeze
49
+ TOKEN = /[!\#$%&'*+.^_`|~0-9A-Za-z-]+/
50
+ PARAMETER = /#{TOKEN}\s*=/
51
+ QUOTED = /"((?:[^"\\]|\\.)*)"/
52
+ AUTHORIZATION_SERVER_PATHS = [
53
+ '/.well-known/oauth-authorization-server%<path>s',
54
+ '/.well-known/openid-configuration%<path>s',
55
+ '%<path>s/.well-known/openid-configuration'
56
+ ].freeze
57
+
58
+ @refreshes = Hash.new { |refreshes, key| refreshes[key] = Mutex.new }
59
+ @refreshes_lock = Mutex.new
60
+
61
+ def self.memory_store
62
+ @memory_store ||= MemoryStore.new
63
+ end
64
+
65
+ # Returns the authorization extensions that the options of MCP.oauth
66
+ # use, as client capabilities declare them, so servers that require
67
+ # an extension know the client follows it.
68
+ def self.extensions(settings)
69
+ return {} unless settings
70
+
71
+ names = []
72
+ names << CLIENT_CREDENTIALS if settings[:grant] == :client_credentials
73
+ names << ENTERPRISE_MANAGED if settings[:identity_provider]
74
+ names.to_h { |name| [name, {}] }
75
+ end
76
+
77
+ # Runs the block while no other thread in this process refreshes the
78
+ # credentials stored under +key+.
79
+ def self.refreshing(key, &)
80
+ @refreshes_lock.synchronize { @refreshes[key] }.synchronize(&)
81
+ end
82
+
83
+ # Reads the challenges of a WWW-Authenticate header (RFC 9110
84
+ # section 11.6.1) into their parameters, keyed by lowercase scheme.
85
+ def self.challenge(header)
86
+ scanner = StringScanner.new(header.to_s)
87
+ challenges = {}
88
+ while scanner.skip(/[\s,]*/) && (scheme = scanner.scan(TOKEN))
89
+ parameters = challenges[scheme.downcase] ||= {}
90
+ while scanner.skip(/[\s,]*/) && scanner.check(PARAMETER)
91
+ name = scanner.scan(TOKEN).downcase.to_sym
92
+ scanner.skip(/\s*=\s*/)
93
+ value = scanner.scan(QUOTED) ? scanner[1].gsub(/\\(.)/, '\1') : scanner.scan(/[^\s,]*/)
94
+ parameters[name] ||= value
95
+ end
96
+ end
97
+ challenges
98
+ end
99
+
100
+ def initialize(server_url, owner:, scopes: nil, client_id: nil, client_secret: nil, grant: nil,
101
+ private_key: nil, assertion: nil, identity_provider: nil, config: RubyLLM.config,
102
+ resolve: ->(value) { value })
103
+ @server_url = server_url.to_s
104
+ @owner = owner
105
+ @scopes = scopes
106
+ @client_id = resolve.call(client_id)
107
+ @client_secret = resolve.call(client_secret)
108
+ @private_key = resolve.call(private_key)
109
+ @assertion = assertion
110
+ @identity_provider = identity_provider
111
+ @grant = grant || (assertion || identity_provider ? :jwt_bearer : :authorization_code)
112
+ @resolve = resolve
113
+ @config = config
114
+ end
115
+
116
+ def authorized?
117
+ credential&.key?('access_token')
118
+ end
119
+
120
+ def access_token
121
+ return unless authorized?
122
+
123
+ renew if expiring?
124
+ credential['access_token']
125
+ end
126
+
127
+ # Returns the headers that authorize a +verb+ request to the server:
128
+ # none without a token, and a proof of possession with a bound one.
129
+ def authorization_headers(verb = 'POST')
130
+ token = access_token or return {}
131
+ return { 'Authorization' => "Bearer #{token}" } unless credential['token_type'] == 'DPoP'
132
+
133
+ proof = proofs.sign(credential['dpop_key'], @server_url, token:, verb:)
134
+ { 'Authorization' => "DPoP #{token}", 'DPoP' => proof }
135
+ end
136
+
137
+ # Answers the server's rejection of a request, given its challenge,
138
+ # its DPoP nonce, and the ways the request +recovered+ before. Returns
139
+ # how to send it again, +:nonce+ with the nonce in its proof or
140
+ # +:token+ with a new token, or +nil+ when it is not worth sending
141
+ # again.
142
+ def recover(challenge, nonce: nil, recovered: [])
143
+ @challenge = challenge
144
+ remember_nonce(nonce)
145
+ recovery = nonce && nonce_requested? ? :nonce : :token
146
+ return if recovered.include?(recovery)
147
+
148
+ recovery if recovery == :nonce || renewed?
149
+ end
150
+
151
+ # Keeps the DPoP nonce the server supplied with a response for the
152
+ # next proof (RFC 9449 section 9).
153
+ def remember_nonce(nonce)
154
+ proofs.remember(@server_url, nonce)
155
+ end
156
+
157
+ def refresh
158
+ return false unless credential&.key?('refresh_token')
159
+
160
+ used = credential['access_token']
161
+ synchronize do
162
+ @credential = store.read(key)
163
+ next true if credential && credential['access_token'] != used
164
+ next false unless credential&.key?('refresh_token')
165
+
166
+ store_tokens(token_request('refresh_token', refresh_token: credential['refresh_token']))
167
+ true
168
+ end
169
+ rescue Error
170
+ false
171
+ end
172
+
173
+ def authorization_url(redirect_uri:, challenge: nil)
174
+ raise ConfigurationError, "The #{@grant} grant needs no authorization" unless authorization_code?
175
+
176
+ @challenge = challenge
177
+ server = authorization_server
178
+ client = client_for(server, redirect_uri)
179
+ verifier = SecureRandom.urlsafe_base64(64)
180
+ state = SecureRandom.urlsafe_base64(32)
181
+ pending = client.merge('state' => state, 'verifier' => verifier, 'redirect_uri' => redirect_uri,
182
+ 'issuer' => server['issuer'], 'scope' => scopes_for(server),
183
+ 'dpop_key' => binding_key, 'expires_at' => Time.now.to_i + PENDING_FOR)
184
+ write(credential.to_h.merge('pending' => pending.merge('server' => server.slice(*SERVER_FIELDS))))
185
+
186
+ query = { response_type: 'code', client_id: client['client_id'], redirect_uri:, state:,
187
+ code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false),
188
+ code_challenge_method: 'S256', resource:, scope: pending['scope'] }.compact
189
+ "#{endpoint(server['authorization_endpoint'])}?#{URI.encode_www_form(query)}"
190
+ end
191
+
192
+ def authorize(params)
193
+ pending = credential&.fetch('pending', nil) or raise Error, 'No authorization in progress'
194
+ check_callback(pending, params)
195
+ tokens = token_request('authorization_code', code: value(params, :code), redirect_uri: pending['redirect_uri'],
196
+ code_verifier: pending['verifier'], client: pending)
197
+ store_tokens(tokens, client: pending.slice(*CLIENT_FIELDS, 'scope'))
198
+ end
199
+
200
+ def deauthorize
201
+ @credential = nil
202
+ store.delete(key)
203
+ end
204
+
205
+ private
206
+
207
+ def credential
208
+ @credential ||= store.read(key)
209
+ end
210
+
211
+ def write(data)
212
+ @credential = data
213
+ store.write(key, data, owner: @owner)
214
+ end
215
+
216
+ def synchronize(&block)
217
+ self.class.refreshing(key) { store.respond_to?(:synchronize) ? store.synchronize(key, &block) : yield }
218
+ end
219
+
220
+ def store_tokens(tokens, client: nil)
221
+ data = credential.to_h.except('pending')
222
+ data = data.except(*CLIENT_FIELDS).merge(client) if client
223
+ data = data.merge('access_token' => tokens['access_token'], 'scope' => tokens['scope'] || data['scope'],
224
+ 'expires_at' => (Time.now.to_i + tokens['expires_in'].to_i if tokens['expires_in']),
225
+ 'token_type' => ('DPoP' if tokens['token_type'].to_s.casecmp?('DPoP')))
226
+ data['refresh_token'] = tokens['refresh_token'] if tokens['refresh_token']
227
+ write(data.compact)
228
+ end
229
+
230
+ def expiring?
231
+ credential['expires_at'] && credential['expires_at'] - REFRESH_EARLY < Time.now.to_i
232
+ end
233
+
234
+ def authorization_code?
235
+ @grant == :authorization_code
236
+ end
237
+
238
+ def renew
239
+ authorization_code? ? refresh : obtain
240
+ end
241
+
242
+ def renewed?
243
+ authorization_code? ? authorized? && refresh : obtain
244
+ end
245
+
246
+ # Requests a token with the configured grant, unless another worker
247
+ # has replaced the one the server rejected. Failures raise
248
+ # UnauthorizedError: Client falls back to the older handshake on an
249
+ # MCP::Error, which would ask the authorization server again.
250
+ def obtain
251
+ rejected = credential&.fetch('access_token', nil)
252
+ synchronize do
253
+ @credential = store.read(key)
254
+ next true if authorized? && credential['access_token'] != rejected && !expiring?
255
+
256
+ server = authorization_server
257
+ client = granting_client(server)
258
+ scope = scopes_for(server)
259
+ grant_type, grant = grant_parameters(server, scope)
260
+ store_tokens(token_request(grant_type, client:, scope:, **grant), client:)
261
+ true
262
+ end
263
+ rescue Error => e
264
+ raise UnauthorizedError.new(e.message, response: e.response)
265
+ end
266
+
267
+ def granting_client(server)
268
+ if @grant == :client_credentials && @client_id.nil?
269
+ raise ConfigurationError, 'The client_credentials grant needs a client_id'
270
+ end
271
+
272
+ preregistered_client(server).merge('issuer' => server['issuer'], 'server' => server.slice(*SERVER_FIELDS),
273
+ 'dpop_key' => binding_key).compact
274
+ end
275
+
276
+ # RFC 9449 section 7.1 and RFC 9728 section 2: a server requires
277
+ # DPoP-bound tokens when it challenges with the DPoP scheme and not
278
+ # Bearer, or when its metadata says so.
279
+ def dpop_required?
280
+ schemes = @challenge.to_h.keys
281
+ @dpop_required || (schemes.include?('dpop') && !schemes.include?('bearer'))
282
+ end
283
+
284
+ def binding_key
285
+ Key.generate.to_pem if dpop_required?
286
+ end
287
+
288
+ # RFC 9449 section 9: a server that wants a nonce in proofs answers
289
+ # with use_dpop_nonce and the nonce to use.
290
+ def nonce_requested?
291
+ @challenge.to_h.dig('dpop', :error) == 'use_dpop_nonce' && credential&.fetch('token_type', nil) == 'DPoP'
292
+ end
293
+
294
+ def proofs
295
+ @proofs ||= Proofs.new
296
+ end
297
+
298
+ # Assertions are resolved for every token, since workload platforms
299
+ # rotate the tokens they issue and ID tokens expire.
300
+ def grant_parameters(server, scope)
301
+ return ['client_credentials', {}] if @grant == :client_credentials
302
+
303
+ assertion = @identity_provider ? identity_assertion(server, scope) : @resolve.call(@assertion)
304
+ raise ConfigurationError, 'The jwt_bearer grant needs an assertion or an identity provider' unless assertion
305
+
306
+ [JWT_BEARER, { assertion: }]
307
+ end
308
+
309
+ # Exchanges the user's ID token at the identity provider for an
310
+ # Identity Assertion JWT Authorization Grant addressed to the
311
+ # server's authorization server (enterprise-managed authorization,
312
+ # section 4). Only that grant is forwarded: any other token the
313
+ # identity provider returns is the user's credential there.
314
+ def identity_assertion(server, scope)
315
+ provider = Hash(@resolve.call(@identity_provider)).to_h { |name, value| [name.to_sym, @resolve.call(value)] }
316
+ unless provider[:issuer] && provider[:id_token]
317
+ raise ConfigurationError, 'The identity provider needs an issuer and an id_token'
318
+ end
319
+
320
+ client = { 'client_id' => provider[:client_id], 'client_secret' => provider[:client_secret],
321
+ 'server' => discover_authorization_server(provider[:issuer]) }
322
+ grant = token_request(TOKEN_EXCHANGE, client:, key: nil, requested_token_type: ID_JAG,
323
+ audience: server['issuer'], scope:, subject_token: provider[:id_token],
324
+ subject_token_type: ID_TOKEN)
325
+ return grant['access_token'] if grant['issued_token_type'] == ID_JAG
326
+
327
+ raise Error, "#{provider[:issuer]} did not issue an identity assertion grant"
328
+ end
329
+
330
+ def check_callback(pending, params)
331
+ raise Error, 'The authorization expired; start again' if pending['expires_at'] < Time.now.to_i
332
+
333
+ check_issuer(pending, value(params, :iss))
334
+ check_state(pending, value(params, :state).to_s)
335
+ error = value(params, :error)
336
+ raise Error, "Authorization failed: #{value(params, :error_description) || error}" if error
337
+ end
338
+
339
+ def check_issuer(pending, issuer)
340
+ raise Error, 'The authorization response came from the wrong issuer' if issuer && issuer != pending['issuer']
341
+ raise Error, 'The authorization server did not identify itself' if issuer.nil? && issuer_required?
342
+ end
343
+
344
+ def check_state(pending, state)
345
+ expected = pending['state']
346
+ return if state.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(state, expected)
347
+
348
+ raise Error, 'The authorization state does not match'
349
+ end
350
+
351
+ def issuer_required?
352
+ credential.dig('pending', 'server', 'authorization_response_iss_parameter_supported') == true
353
+ end
354
+
355
+ def token_request(grant_type, client: credential, key: (signing_key if client['client_id'] == @client_id),
356
+ **params)
357
+ server = client['server'] or raise Error, 'No authorization server known; authorize first'
358
+ send_token_request(server, client, key, params.merge(grant_type:))
359
+ rescue Error => e
360
+ forget_registration(client) if oauth_error(e) == 'invalid_client'
361
+ raise
362
+ end
363
+
364
+ # RFC 9449 section 8: an authorization server that wants a nonce in
365
+ # the proof answers use_dpop_nonce once with the nonce to use. Every
366
+ # attempt signs a new proof and a new client assertion.
367
+ def send_token_request(server, client, key, params, retried: false)
368
+ url = server['token_endpoint']
369
+ form = params.merge(client_id: client['client_id'], resource:)
370
+ headers = { 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json' }
371
+ authenticate(client, server, form, headers, key)
372
+ headers['DPoP'] = proofs.sign(client['dpop_key'], url) if client['dpop_key']
373
+ post(url, URI.encode_www_form(form.compact), headers)
374
+ rescue Error => e
375
+ raise if retried || !client['dpop_key'] || oauth_error(e) != 'use_dpop_nonce'
376
+
377
+ send_token_request(server, client, key, params, retried: true)
378
+ end
379
+
380
+ def oauth_error(error)
381
+ error.data['error'] if error.data.is_a?(Hash)
382
+ end
383
+
384
+ def forget_registration(client)
385
+ registration = registration_key(client['issuer'], client['redirect_uri'])
386
+ store.delete(registration) if store.read(registration)&.fetch('client_id', nil) == client['client_id']
387
+ end
388
+
389
+ def authenticate(client, server, form, headers, key)
390
+ if key
391
+ form.delete(:client_id)
392
+ form.merge!(client_assertion_type: CLIENT_ASSERTION,
393
+ client_assertion: client_assertion(key, client['client_id'], server))
394
+ elsif client['client_secret']
395
+ methods = server['token_endpoint_auth_methods_supported'] || ['client_secret_basic']
396
+ if methods.include?('client_secret_basic')
397
+ headers['Authorization'] = "Basic #{basic_credentials(client['client_id'], client['client_secret'])}"
398
+ else
399
+ form[:client_secret] = client['client_secret']
400
+ end
401
+ end
402
+ end
403
+
404
+ # RFC 6749 section 2.3.1: the client ID and secret are form-encoded
405
+ # before they become the user and password of Basic authentication.
406
+ def basic_credentials(client_id, client_secret)
407
+ Base64.strict_encode64([client_id, client_secret].map { |part| URI.encode_www_form_component(part) }.join(':'))
408
+ end
409
+
410
+ # RFC 7523bis section 4: the issuer is the sole audience, so no other
411
+ # authorization server can replay the assertion, and the explicit
412
+ # type tells servers the client follows that rule.
413
+ def client_assertion(key, client_id, server)
414
+ now = Time.now.to_i
415
+ claims = { iss: client_id, sub: client_id, aud: server['issuer'], iat: now, exp: now + ASSERTION_FOR,
416
+ jti: SecureRandom.uuid }
417
+ algorithm = key.algorithm(server['token_endpoint_auth_signing_alg_values_supported'])
418
+ key.jwt(claims, algorithm:, typ: 'client-authentication+jwt')
419
+ end
420
+
421
+ def signing_key
422
+ @signing_key ||= (Key.new(@private_key) if @private_key)
423
+ end
424
+
425
+ def authorization_server
426
+ metadata = protected_resource_metadata
427
+ server = metadata ? described_authorization_server(metadata) : legacy_authorization_server
428
+ if authorization_code? && !Array(server['code_challenge_methods_supported']).include?('S256')
429
+ raise Error, "#{server['issuer']} does not support PKCE with S256"
430
+ end
431
+
432
+ server
433
+ end
434
+
435
+ def described_authorization_server(metadata)
436
+ issuer = Array(metadata['authorization_servers']).first
437
+ raise Error, "#{@server_url} names no authorization server" unless issuer
438
+
439
+ @resource = checked_resource(metadata['resource'])
440
+ @scopes_supported = metadata['scopes_supported']
441
+ @dpop_required = metadata['dpop_bound_access_tokens_required'] == true
442
+ discover_authorization_server(issuer)
443
+ end
444
+
445
+ def protected_resource_metadata
446
+ url = challenged(:resource_metadata)
447
+ return get_json(url) if url && same_origin?(URI(url), URI(@server_url))
448
+
449
+ uri = URI(@server_url)
450
+ path = uri.path.chomp('/')
451
+ candidates = ["/.well-known/oauth-protected-resource#{path}", '/.well-known/oauth-protected-resource'].uniq
452
+ first_json(candidates.map { |candidate| URI.join(uri, candidate).to_s })
453
+ end
454
+
455
+ # Servers from the 2025-03-26 revision publish no protected resource
456
+ # metadata: their own origin is the authorization server, with default
457
+ # endpoints when it publishes no metadata either.
458
+ def legacy_authorization_server
459
+ origin = URI.join(@server_url, '/').to_s.chomp('/')
460
+ first_json(["#{origin}/.well-known/oauth-authorization-server"]) || {
461
+ 'issuer' => origin, 'authorization_endpoint' => "#{origin}/authorize",
462
+ 'token_endpoint' => "#{origin}/token", 'registration_endpoint' => "#{origin}/register",
463
+ 'code_challenge_methods_supported' => ['S256']
464
+ }
465
+ end
466
+
467
+ def checked_resource(resource)
468
+ return unless resource
469
+ return resource if covers?(URI(resource), URI(@server_url))
470
+
471
+ raise Error, "#{@server_url} published metadata for another resource: #{resource}"
472
+ end
473
+
474
+ def same_origin?(url, server)
475
+ [url.scheme, url.host, url.port] == [server.scheme, server.host, server.port]
476
+ end
477
+
478
+ def covers?(resource, server)
479
+ return false if resource.userinfo || server.userinfo
480
+ return false unless same_origin?(resource, server)
481
+
482
+ path = resource.path.chomp('/')
483
+ server.path == path || server.path.start_with?("#{path}/")
484
+ end
485
+
486
+ def discover_authorization_server(issuer)
487
+ server = authorization_server_metadata(issuer)
488
+ return server if same_issuer?(server['issuer'], issuer)
489
+
490
+ named = server['issuer']
491
+ raise Error, "#{issuer} metadata names a different issuer" unless named.is_a?(String) && URI(named).host
492
+
493
+ server = authorization_server_metadata(named)
494
+ raise Error, "#{issuer} metadata names a different issuer" unless same_issuer?(server['issuer'], named)
495
+
496
+ server
497
+ rescue URI::InvalidURIError
498
+ raise Error, "#{issuer} metadata names a different issuer"
499
+ end
500
+
501
+ def authorization_server_metadata(issuer)
502
+ uri = URI(issuer)
503
+ path = uri.path.chomp('/')
504
+ urls = AUTHORIZATION_SERVER_PATHS.map { |pattern| URI.join(uri, format(pattern, path:)).to_s }
505
+ urls = urls.first(2) if path.empty?
506
+ first_json(urls.uniq) or raise Error, "#{issuer} publishes no authorization server metadata"
507
+ end
508
+
509
+ # RFC 3986 section 6.2.3: an empty path and "/" name the same resource.
510
+ def same_issuer?(one, other)
511
+ [one, other].map { |issuer| issuer.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://[^/?#]+)/(?=[?#]|\z)}i, '\\1') }.uniq.one?
512
+ end
513
+
514
+ def client_for(server, redirect_uri)
515
+ return preregistered_client(server) if @client_id
516
+
517
+ metadata_client_id = @config.mcp_client_id
518
+ if metadata_client_id && server['client_id_metadata_document_supported']
519
+ return { 'client_id' => metadata_client_id }
520
+ end
521
+
522
+ register(server, redirect_uri)
523
+ end
524
+
525
+ def preregistered_client(server)
526
+ issuer_key = "issuer:#{@client_id} #{@server_url}"
527
+ issuer = store.read(issuer_key)&.fetch('issuer', nil)
528
+ store.write(issuer_key, { 'issuer' => server['issuer'] }, owner: nil) unless issuer
529
+ if issuer && !same_issuer?(issuer, server['issuer'])
530
+ raise Error, "#{@client_id || 'The workload'} is registered with #{issuer}, " \
531
+ "but #{@server_url} now uses #{server['issuer']}"
532
+ end
533
+
534
+ { 'client_id' => @client_id, 'client_secret' => @client_secret }.compact
535
+ end
536
+
537
+ def register(server, redirect_uri)
538
+ endpoint = server['registration_endpoint'] or raise Error, "#{server['issuer']} does not register clients"
539
+ registration = registration_key(server['issuer'], redirect_uri)
540
+ scope = scopes_for(server)
541
+ registered = store.read(registration)
542
+ if registered && registered_for?(registered['scope'], scope)
543
+ return registered.slice('client_id', 'client_secret')
544
+ end
545
+
546
+ body = JSON.generate({
547
+ client_name: @config.mcp_client_name, redirect_uris: [redirect_uri], response_types: ['code'],
548
+ grant_types: %w[authorization_code refresh_token], token_endpoint_auth_method: 'none',
549
+ application_type: HTTP.loopback?(redirect_uri) ? 'native' : 'web', scope:
550
+ }.compact)
551
+ client = post(endpoint, body, 'Content-Type' => 'application/json').slice('client_id', 'client_secret')
552
+ store.write(registration, client.merge('scope' => scope).compact, owner: nil)
553
+ client
554
+ end
555
+
556
+ def registered_for?(registered, requested)
557
+ (requested.to_s.split - registered.to_s.split).empty?
558
+ end
559
+
560
+ def registration_key(issuer, redirect_uri)
561
+ "client:#{issuer} #{redirect_uri}"
562
+ end
563
+
564
+ def scopes_for(server)
565
+ requested = challenged(:scope)&.split
566
+ scopes = if requested then Array(@scopes) + requested + credential.to_h['scope'].to_s.split
567
+ else Array(@scopes || @scopes_supported)
568
+ end
569
+ scopes += ['offline_access'] if offline_access?(server)
570
+ scopes.uniq.join(' ') unless scopes.empty?
571
+ end
572
+
573
+ def offline_access?(server)
574
+ authorization_code? && Array(server['scopes_supported']).include?('offline_access')
575
+ end
576
+
577
+ def challenged(name)
578
+ @challenge.to_h.each_value.filter_map { |parameters| parameters[name] }.first
579
+ end
580
+
581
+ def resource
582
+ @resource || @server_url.chomp('/')
583
+ end
584
+
585
+ def first_json(urls)
586
+ urls.each do |url|
587
+ return get_json(url)
588
+ rescue Faraday::Error, Error
589
+ next
590
+ end
591
+ nil
592
+ end
593
+
594
+ def get_json(url)
595
+ parse(connection(url).get(url, nil, 'Accept' => 'application/json'))
596
+ end
597
+
598
+ def post(url, body, headers)
599
+ response = connection(url).post(url, body, headers)
600
+ proofs.remember(url, response.headers['dpop-nonce'])
601
+ parse(response)
602
+ rescue Faraday::Error => e
603
+ raise refusal(url, e.response && Faraday::Response.new(status: e.response[:status], body: e.response[:body],
604
+ response_headers: e.response[:headers]))
605
+ end
606
+
607
+ def refusal(url, response)
608
+ proofs.remember(url, Hash(response&.headers)['dpop-nonce'])
609
+ details = begin
610
+ JSON.parse(response&.body.to_s)
611
+ rescue JSON::ParserError
612
+ {}
613
+ end
614
+ details = {} unless details.is_a?(Hash)
615
+ Error.new("#{URI(url).host} refused the request: #{details['error_description'] || details['error']}",
616
+ data: details, response:)
617
+ end
618
+
619
+ def parse(response)
620
+ JSON.parse(response.body)
621
+ rescue JSON::ParserError
622
+ raise Error, 'The authorization server did not answer with JSON'
623
+ end
624
+
625
+ def connection(url)
626
+ endpoint(url)
627
+ Transport::Connection.basic(@config) { |faraday| faraday.adapter(@config.faraday_adapter) }
628
+ end
629
+
630
+ def endpoint(url)
631
+ uri = URI(url.to_s)
632
+ loopback_allowed = HTTP.loopback?(@server_url)
633
+ return url if uri.scheme == 'https' && uri.userinfo.nil?
634
+ return url if loopback_allowed && HTTP.secure?(uri)
635
+
636
+ raise Error, "OAuth endpoints must use HTTPS: #{url}"
637
+ end
638
+
639
+ def value(params, name)
640
+ params[name] || params[name.to_s]
641
+ end
642
+
643
+ def store
644
+ @config.mcp_credential_store || self.class.memory_store
645
+ end
646
+
647
+ def key
648
+ owner = @owner.respond_to?(:to_gid) ? @owner.to_gid.to_s : @owner.to_s
649
+ "#{owner}@#{@server_url}"
650
+ end
651
+ end
652
+ end
653
+ end