ruby_llm 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +65 -8
- data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
- data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
- data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
- data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
- data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
- data/lib/ruby_llm/accounting/usage.rb +86 -49
- data/lib/ruby_llm/active_record/acts_as.rb +3 -0
- data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
- data/lib/ruby_llm/active_record/batch.rb +1 -1
- data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
- data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
- data/lib/ruby_llm/active_record/message_methods.rb +32 -7
- data/lib/ruby_llm/active_record/model.rb +1 -1
- data/lib/ruby_llm/active_record/provider_file.rb +64 -0
- data/lib/ruby_llm/active_record/record.rb +19 -0
- data/lib/ruby_llm/active_record/tool_call.rb +1 -1
- data/lib/ruby_llm/active_record/usage.rb +53 -3
- data/lib/ruby_llm/agent.rb +198 -33
- data/lib/ruby_llm/aliases.json +66 -8
- data/lib/ruby_llm/attachment.rb +54 -10
- data/lib/ruby_llm/batch.rb +26 -3
- data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
- data/lib/ruby_llm/chat/tool_search.rb +26 -0
- data/lib/ruby_llm/chat.rb +488 -82
- data/lib/ruby_llm/choice.rb +38 -0
- data/lib/ruby_llm/configuration.rb +36 -11
- data/lib/ruby_llm/context.rb +45 -32
- data/lib/ruby_llm/cost.rb +42 -1
- data/lib/ruby_llm/embedding.rb +9 -2
- data/lib/ruby_llm/error.rb +34 -2
- data/lib/ruby_llm/evaluation/assertions.rb +40 -0
- data/lib/ruby_llm/evaluation/case.rb +52 -0
- data/lib/ruby_llm/evaluation/dataset.rb +65 -0
- data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
- data/lib/ruby_llm/evaluation/evidence.rb +125 -0
- data/lib/ruby_llm/evaluation/minitest.rb +26 -0
- data/lib/ruby_llm/evaluation/report.rb +103 -0
- data/lib/ruby_llm/evaluation/result.rb +75 -0
- data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
- data/lib/ruby_llm/evaluation/rspec.rb +23 -0
- data/lib/ruby_llm/evaluation/runner.rb +93 -0
- data/lib/ruby_llm/evaluation/trial.rb +101 -0
- data/lib/ruby_llm/evaluation.rb +289 -0
- data/lib/ruby_llm/image.rb +12 -4
- data/lib/ruby_llm/judge/builder.rb +51 -0
- data/lib/ruby_llm/judge/data.rb +43 -0
- data/lib/ruby_llm/judge/question.rb +101 -0
- data/lib/ruby_llm/judge.rb +179 -0
- data/lib/ruby_llm/judgment.rb +125 -0
- data/lib/ruby_llm/mcp/apps.rb +25 -0
- data/lib/ruby_llm/mcp/client.rb +213 -0
- data/lib/ruby_llm/mcp/collection.rb +56 -0
- data/lib/ruby_llm/mcp/content.rb +44 -0
- data/lib/ruby_llm/mcp/error.rb +27 -0
- data/lib/ruby_llm/mcp/http.rb +379 -0
- data/lib/ruby_llm/mcp/input_request.rb +118 -0
- data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
- data/lib/ruby_llm/mcp/listener.rb +168 -0
- data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
- data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
- data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
- data/lib/ruby_llm/mcp/oauth.rb +653 -0
- data/lib/ruby_llm/mcp/param_headers.rb +83 -0
- data/lib/ruby_llm/mcp/prompt.rb +71 -0
- data/lib/ruby_llm/mcp/resource.rb +84 -0
- data/lib/ruby_llm/mcp/resource_template.rb +99 -0
- data/lib/ruby_llm/mcp/result.rb +76 -0
- data/lib/ruby_llm/mcp/session_expired.rb +10 -0
- data/lib/ruby_llm/mcp/stdio.rb +250 -0
- data/lib/ruby_llm/mcp/task.rb +151 -0
- data/lib/ruby_llm/mcp/tool.rb +130 -0
- data/lib/ruby_llm/mcp.rb +1101 -0
- data/lib/ruby_llm/message.rb +41 -6
- data/lib/ruby_llm/model.rb +2 -1
- data/lib/ruby_llm/models/aliases.rb +5 -2
- data/lib/ruby_llm/models/lookup.rb +80 -0
- data/lib/ruby_llm/models/registry.rb +2 -2
- data/lib/ruby_llm/models/schema.rb +1 -1
- data/lib/ruby_llm/models.json +23082 -10158
- data/lib/ruby_llm/models.rb +20 -69
- data/lib/ruby_llm/moderation.rb +7 -2
- data/lib/ruby_llm/ocr.rb +7 -2
- data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
- data/lib/ruby_llm/open_telemetry.rb +82 -0
- data/lib/ruby_llm/probability.rb +30 -0
- data/lib/ruby_llm/progress.rb +44 -0
- data/lib/ruby_llm/prompt/context.rb +55 -0
- data/lib/ruby_llm/prompt.rb +1 -1
- data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
- data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
- data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
- data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
- data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
- data/lib/ruby_llm/protocol/streaming.rb +38 -31
- data/lib/ruby_llm/protocol.rb +192 -40
- data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
- data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
- data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
- data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
- data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
- data/lib/ruby_llm/protocols/anthropic.rb +20 -1
- data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
- data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
- data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
- data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
- data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
- data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
- data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
- data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
- data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
- data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
- data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
- data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
- data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
- data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
- data/lib/ruby_llm/protocols/cohere.rb +1 -0
- data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
- data/lib/ruby_llm/protocols/converse/media.rb +16 -2
- data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
- data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
- data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
- data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
- data/lib/ruby_llm/protocols/converse.rb +3 -5
- data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
- data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
- data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
- data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
- data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
- data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
- data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
- data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
- data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
- data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
- data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
- data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
- data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
- data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
- data/lib/ruby_llm/protocols/gemini.rb +1 -0
- data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
- data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
- data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
- data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
- data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
- data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
- data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
- data/lib/ruby_llm/protocols/interactions.rb +1 -0
- data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
- data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
- data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
- data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
- data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
- data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
- data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
- data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
- data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
- data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
- data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
- data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
- data/lib/ruby_llm/protocols/openai/files.rb +0 -7
- data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
- data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
- data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
- data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
- data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
- data/lib/ruby_llm/protocols/responses/media.rb +11 -7
- data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
- data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
- data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
- data/lib/ruby_llm/protocols/responses.rb +1 -0
- data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
- data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
- data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
- data/lib/ruby_llm/protocols/system_one.rb +27 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
- data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
- data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
- data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
- data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
- data/lib/ruby_llm/provider.rb +87 -15
- data/lib/ruby_llm/providers/anthropic.rb +23 -0
- data/lib/ruby_llm/providers/azure/chat.rb +4 -0
- data/lib/ruby_llm/providers/azure/images.rb +2 -2
- data/lib/ruby_llm/providers/azure/media.rb +8 -3
- data/lib/ruby_llm/providers/azure/videos.rb +7 -2
- data/lib/ruby_llm/providers/azure.rb +24 -4
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
- data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
- data/lib/ruby_llm/providers/bedrock.rb +38 -6
- data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
- data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
- data/lib/ruby_llm/providers/deepseek.rb +4 -0
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
- data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
- data/lib/ruby_llm/providers/hetzner.rb +41 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
- data/lib/ruby_llm/providers/mistral/media.rb +8 -4
- data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
- data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
- data/lib/ruby_llm/providers/ollama/media.rb +6 -2
- data/lib/ruby_llm/providers/ollama/models.rb +2 -0
- data/lib/ruby_llm/providers/ollama.rb +11 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
- data/lib/ruby_llm/providers/openai.rb +14 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
- data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
- data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
- data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
- data/lib/ruby_llm/providers/openrouter.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
- data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
- data/lib/ruby_llm/providers/perplexity.rb +11 -0
- data/lib/ruby_llm/providers/typesafe.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
- data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
- data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/vertexai.rb +27 -28
- data/lib/ruby_llm/providers/xai/chat.rb +6 -0
- data/lib/ruby_llm/providers/xai/images.rb +9 -10
- data/lib/ruby_llm/providers/xai/responses.rb +10 -8
- data/lib/ruby_llm/providers/xai/videos.rb +11 -9
- data/lib/ruby_llm/providers/xai.rb +4 -0
- data/lib/ruby_llm/railtie.rb +11 -0
- data/lib/ruby_llm/request_shape/part.rb +117 -0
- data/lib/ruby_llm/request_shape/problem.rb +63 -0
- data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
- data/lib/ruby_llm/request_shape/turn.rb +54 -0
- data/lib/ruby_llm/request_shape.rb +160 -0
- data/lib/ruby_llm/rerank.rb +9 -2
- data/lib/ruby_llm/research_job.rb +21 -5
- data/lib/ruby_llm/score.rb +42 -0
- data/lib/ruby_llm/search_results.rb +2 -2
- data/lib/ruby_llm/server_tool_call.rb +10 -2
- data/lib/ruby_llm/speech.rb +8 -1
- data/lib/ruby_llm/support/cancellation.rb +39 -0
- data/lib/ruby_llm/support/inspectable.rb +3 -2
- data/lib/ruby_llm/support/instrumentation.rb +38 -1
- data/lib/ruby_llm/support/process_cache.rb +60 -0
- data/lib/ruby_llm/support/progress_reporter.rb +33 -0
- data/lib/ruby_llm/thinking.rb +2 -1
- data/lib/ruby_llm/tokens.rb +57 -14
- data/lib/ruby_llm/tool.rb +50 -3
- data/lib/ruby_llm/tool_call.rb +17 -2
- data/lib/ruby_llm/transcription.rb +10 -3
- data/lib/ruby_llm/transport/connection.rb +127 -44
- data/lib/ruby_llm/transport/error_middleware.rb +90 -24
- data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
- data/lib/ruby_llm/transport/json_response.rb +33 -0
- data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm/video.rb +5 -3
- data/lib/ruby_llm/video_job.rb +48 -5
- data/lib/ruby_llm.rb +106 -5
- data/lib/tasks/ruby_llm/evaluations.rb +43 -0
- data/lib/tasks/ruby_llm.rake +6 -9
- data/skills/rubyllm/SKILL.md +33 -12
- metadata +95 -37
- data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
- data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
- data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
- data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
- data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
- data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
- data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
- data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
data/lib/ruby_llm/mcp.rb
ADDED
|
@@ -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
|