groq_ruby 0.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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +57 -0
- data/CLAUDE.md +103 -0
- data/LICENSE.txt +21 -0
- data/README.md +495 -0
- data/Rakefile +11 -0
- data/examples/README.md +39 -0
- data/examples/batch.rb +29 -0
- data/examples/chat_completion.rb +24 -0
- data/examples/chat_completion_stop.rb +19 -0
- data/examples/chat_completion_streaming.rb +23 -0
- data/examples/embedding.rb +20 -0
- data/examples/error_handling.rb +27 -0
- data/examples/file_upload.rb +23 -0
- data/examples/mcp_agent.rb +63 -0
- data/examples/mcp_chat_with_tools.rb +103 -0
- data/examples/mcp_resources_and_prompts.rb +89 -0
- data/examples/models_list.rb +16 -0
- data/examples/speech.rb +23 -0
- data/examples/transcription.rb +23 -0
- data/examples/translation.rb +22 -0
- data/lib/groq_ruby/client.rb +69 -0
- data/lib/groq_ruby/configuration.rb +62 -0
- data/lib/groq_ruby/error_mapper.rb +37 -0
- data/lib/groq_ruby/errors/api_connection_error.rb +8 -0
- data/lib/groq_ruby/errors/api_error.rb +14 -0
- data/lib/groq_ruby/errors/api_response_error.rb +5 -0
- data/lib/groq_ruby/errors/api_status_error.rb +23 -0
- data/lib/groq_ruby/errors/api_timeout_error.rb +8 -0
- data/lib/groq_ruby/errors/authentication_error.rb +4 -0
- data/lib/groq_ruby/errors/bad_request_error.rb +4 -0
- data/lib/groq_ruby/errors/configuration_error.rb +4 -0
- data/lib/groq_ruby/errors/conflict_error.rb +4 -0
- data/lib/groq_ruby/errors/error.rb +5 -0
- data/lib/groq_ruby/errors/internal_server_error.rb +4 -0
- data/lib/groq_ruby/errors/not_found_error.rb +4 -0
- data/lib/groq_ruby/errors/parameter_error.rb +13 -0
- data/lib/groq_ruby/errors/permission_denied_error.rb +4 -0
- data/lib/groq_ruby/errors/rate_limit_error.rb +4 -0
- data/lib/groq_ruby/errors/unprocessable_entity_error.rb +4 -0
- data/lib/groq_ruby/mcp/bridge.rb +239 -0
- data/lib/groq_ruby/mcp/claude_desktop_config.rb +79 -0
- data/lib/groq_ruby/mcp/client.rb +171 -0
- data/lib/groq_ruby/mcp/errors/error.rb +7 -0
- data/lib/groq_ruby/mcp/errors/json_rpc_error.rb +21 -0
- data/lib/groq_ruby/mcp/errors/protocol_error.rb +7 -0
- data/lib/groq_ruby/mcp/errors/timeout_error.rb +7 -0
- data/lib/groq_ruby/mcp/errors/transport_error.rb +6 -0
- data/lib/groq_ruby/mcp/errors/unknown_tool_error.rb +7 -0
- data/lib/groq_ruby/mcp/json_rpc.rb +51 -0
- data/lib/groq_ruby/mcp/prompt.rb +21 -0
- data/lib/groq_ruby/mcp/resource.rb +17 -0
- data/lib/groq_ruby/mcp/server_config.rb +22 -0
- data/lib/groq_ruby/mcp/tool.rb +22 -0
- data/lib/groq_ruby/mcp/transport.rb +32 -0
- data/lib/groq_ruby/mcp/transports/stdio.rb +100 -0
- data/lib/groq_ruby/mcp.rb +25 -0
- data/lib/groq_ruby/models/audio/transcription.rb +10 -0
- data/lib/groq_ruby/models/audio/translation.rb +8 -0
- data/lib/groq_ruby/models/batches/batch.rb +16 -0
- data/lib/groq_ruby/models/batches/batch_list.rb +10 -0
- data/lib/groq_ruby/models/batches/batch_request_counts.rb +8 -0
- data/lib/groq_ruby/models/chat/chat_completion.rb +14 -0
- data/lib/groq_ruby/models/chat/chat_completion_choice.rb +10 -0
- data/lib/groq_ruby/models/chat/chat_completion_chunk.rb +13 -0
- data/lib/groq_ruby/models/chat/chat_completion_chunk_choice.rb +10 -0
- data/lib/groq_ruby/models/chat/chat_completion_delta.rb +8 -0
- data/lib/groq_ruby/models/chat/chat_completion_message.rb +10 -0
- data/lib/groq_ruby/models/embeddings/create_embedding_response.rb +11 -0
- data/lib/groq_ruby/models/embeddings/embedding.rb +8 -0
- data/lib/groq_ruby/models/embeddings/embedding_usage.rb +8 -0
- data/lib/groq_ruby/models/files/file_deleted.rb +8 -0
- data/lib/groq_ruby/models/files/file_list.rb +10 -0
- data/lib/groq_ruby/models/files/file_object.rb +8 -0
- data/lib/groq_ruby/models/model.rb +8 -0
- data/lib/groq_ruby/models/model_deleted.rb +8 -0
- data/lib/groq_ruby/models/model_factory.rb +31 -0
- data/lib/groq_ruby/models/model_list.rb +10 -0
- data/lib/groq_ruby/models/usage.rb +11 -0
- data/lib/groq_ruby/multipart.rb +84 -0
- data/lib/groq_ruby/request.rb +13 -0
- data/lib/groq_ruby/resources/audio/speech.rb +32 -0
- data/lib/groq_ruby/resources/audio/transcriptions.rb +48 -0
- data/lib/groq_ruby/resources/audio/translations.rb +45 -0
- data/lib/groq_ruby/resources/audio.rb +26 -0
- data/lib/groq_ruby/resources/base.rb +33 -0
- data/lib/groq_ruby/resources/batches.rb +44 -0
- data/lib/groq_ruby/resources/chat/completions.rb +94 -0
- data/lib/groq_ruby/resources/chat.rb +16 -0
- data/lib/groq_ruby/resources/embeddings.rb +28 -0
- data/lib/groq_ruby/resources/files.rb +55 -0
- data/lib/groq_ruby/resources/models.rb +35 -0
- data/lib/groq_ruby/response.rb +9 -0
- data/lib/groq_ruby/streaming/chunk_stream.rb +58 -0
- data/lib/groq_ruby/streaming/event_parser.rb +23 -0
- data/lib/groq_ruby/transport.rb +169 -0
- data/lib/groq_ruby/version.rb +5 -0
- data/lib/groq_ruby.rb +36 -0
- data/lib/tasks/gem.rake +5 -0
- data/lib/tasks/lint/all.rake +11 -0
- data/lib/tasks/lint/rubocop.rake +15 -0
- data/lib/tasks/security.rake +11 -0
- data/lib/tasks/types.rake +11 -0
- data/sig/groq_ruby.rbs +191 -0
- data/sig/zeitwerk.rbs +13 -0
- data.tar.gz.sig +0 -0
- metadata +237 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module GroqRuby
|
|
4
|
+
# Immutable per-client configuration. Built once when the {Client} is
|
|
5
|
+
# constructed; never mutated afterwards. No global state — each client
|
|
6
|
+
# carries its own configuration, so multi-tenant code can hold multiple
|
|
7
|
+
# clients without leakage.
|
|
8
|
+
#
|
|
9
|
+
# @example Build from environment
|
|
10
|
+
# config = GroqRuby::Configuration.from_env
|
|
11
|
+
#
|
|
12
|
+
# @example Explicit construction
|
|
13
|
+
# config = GroqRuby::Configuration.from_env(api_key: "...", base_url: "https://api.groq.com")
|
|
14
|
+
class Configuration < Data.define(:api_key, :base_url, :open_timeout, :read_timeout, :user_agent)
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.groq.com".freeze
|
|
16
|
+
DEFAULT_OPEN_TIMEOUT = 10.0
|
|
17
|
+
DEFAULT_READ_TIMEOUT = 60.0
|
|
18
|
+
DEFAULT_USER_AGENT = "groq_ruby/#{GroqRuby::VERSION} (ruby; net-http)".freeze
|
|
19
|
+
|
|
20
|
+
# Build a configuration from environment variables, with overrides.
|
|
21
|
+
# Reads `GROQ_API_KEY` and `GROQ_BASE_URL`.
|
|
22
|
+
#
|
|
23
|
+
# @param api_key [String, nil] explicit key (falls back to `GROQ_API_KEY`)
|
|
24
|
+
# @param base_url [String, nil] explicit base URL (falls back to `GROQ_BASE_URL`)
|
|
25
|
+
# @param open_timeout [Numeric, nil] connect-phase timeout in seconds
|
|
26
|
+
# @param read_timeout [Numeric, nil] socket-read timeout in seconds
|
|
27
|
+
# @param user_agent [String, nil] override the default User-Agent header
|
|
28
|
+
# @return [Configuration]
|
|
29
|
+
# @raise [ConfigurationError] if no api_key is provided or in env.
|
|
30
|
+
def self.from_env(api_key: nil, base_url: nil, open_timeout: nil, read_timeout: nil, user_agent: nil)
|
|
31
|
+
new(
|
|
32
|
+
api_key: api_key || ENV["GROQ_API_KEY"],
|
|
33
|
+
base_url: base_url || ENV["GROQ_BASE_URL"] || DEFAULT_BASE_URL,
|
|
34
|
+
open_timeout: open_timeout || DEFAULT_OPEN_TIMEOUT,
|
|
35
|
+
read_timeout: read_timeout || DEFAULT_READ_TIMEOUT,
|
|
36
|
+
user_agent: user_agent || DEFAULT_USER_AGENT
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
41
|
+
read_timeout: DEFAULT_READ_TIMEOUT, user_agent: DEFAULT_USER_AGENT)
|
|
42
|
+
raise ConfigurationError, "api_key is required (or set GROQ_API_KEY)" if api_key.nil? || api_key.empty?
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The parsed base URI used to build per-request URIs.
|
|
47
|
+
# @return [URI::Generic]
|
|
48
|
+
def base_uri
|
|
49
|
+
@base_uri ||= URI.parse(base_url)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Default headers attached to every request.
|
|
53
|
+
# @return [Hash{String => String}]
|
|
54
|
+
def default_headers
|
|
55
|
+
{
|
|
56
|
+
"Authorization" => "Bearer #{api_key}",
|
|
57
|
+
"User-Agent" => user_agent,
|
|
58
|
+
"Accept" => "application/json"
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module GroqRuby
|
|
2
|
+
# Maps an HTTP status code + parsed body to the matching {APIStatusError}
|
|
3
|
+
# subclass. Single-purpose: status in, exception instance out.
|
|
4
|
+
class ErrorMapper
|
|
5
|
+
STATUS_CLASSES = {
|
|
6
|
+
400 => BadRequestError,
|
|
7
|
+
401 => AuthenticationError,
|
|
8
|
+
403 => PermissionDeniedError,
|
|
9
|
+
404 => NotFoundError,
|
|
10
|
+
409 => ConflictError,
|
|
11
|
+
422 => UnprocessableEntityError,
|
|
12
|
+
429 => RateLimitError
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# @param status [Integer] HTTP status code
|
|
16
|
+
# @param headers [Hash] response headers
|
|
17
|
+
# @param body [Object, nil] decoded JSON body (Hash) or raw String
|
|
18
|
+
# @return [APIStatusError]
|
|
19
|
+
def self.call(status:, headers:, body:)
|
|
20
|
+
klass = resolve_class(status)
|
|
21
|
+
message = extract_message(body) || "Groq API returned status #{status}"
|
|
22
|
+
klass.new(message, status: status, headers: headers, body: body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.resolve_class(status)
|
|
26
|
+
STATUS_CLASSES[status] || ((status >= 500) ? InternalServerError : APIStatusError)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.extract_message(body)
|
|
30
|
+
return nil unless body.is_a?(Hash)
|
|
31
|
+
err = body["error"] || body[:error]
|
|
32
|
+
err.is_a?(Hash) ? (err["message"] || err[:message]) : err
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method :resolve_class, :extract_message
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module GroqRuby
|
|
2
|
+
# Parent for everything that happens while talking to the Groq API.
|
|
3
|
+
class APIError < Error
|
|
4
|
+
# @return [Object, nil] the originating request when available
|
|
5
|
+
attr_reader :request
|
|
6
|
+
|
|
7
|
+
# @param message [String]
|
|
8
|
+
# @param request [Object, nil]
|
|
9
|
+
def initialize(message, request: nil)
|
|
10
|
+
@request = request
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module GroqRuby
|
|
2
|
+
# 4xx/5xx response. Carries status, headers, and the parsed body.
|
|
3
|
+
class APIStatusError < APIError
|
|
4
|
+
# @return [Integer] HTTP status code
|
|
5
|
+
attr_reader :status
|
|
6
|
+
# @return [Hash{String => String}] response headers
|
|
7
|
+
attr_reader :headers
|
|
8
|
+
# @return [Hash, String, nil] decoded JSON body or raw String
|
|
9
|
+
attr_reader :body
|
|
10
|
+
|
|
11
|
+
# @param message [String]
|
|
12
|
+
# @param status [Integer]
|
|
13
|
+
# @param headers [Hash{String => String}]
|
|
14
|
+
# @param body [Hash, String, nil]
|
|
15
|
+
# @param request [Object, nil]
|
|
16
|
+
def initialize(message, status:, headers: {}, body: nil, request: nil)
|
|
17
|
+
@status = status
|
|
18
|
+
@headers = headers
|
|
19
|
+
@body = body
|
|
20
|
+
super(message, request: request)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module GroqRuby
|
|
2
|
+
# Raised when a request fails dry-schema validation before being sent.
|
|
3
|
+
class ParameterError < Error
|
|
4
|
+
# @return [Hash] dry-schema error messages, keyed by parameter name
|
|
5
|
+
attr_reader :messages
|
|
6
|
+
|
|
7
|
+
# @param messages [Hash]
|
|
8
|
+
def initialize(messages)
|
|
9
|
+
@messages = messages
|
|
10
|
+
super("invalid parameters: #{messages.inspect}")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module GroqRuby
|
|
4
|
+
module MCP
|
|
5
|
+
# Glues one or more MCP servers into a Groq chat completion. Connects
|
|
6
|
+
# to each server, builds the OpenAI-style `tools:` array the model
|
|
7
|
+
# expects, and routes each `tool_calls[*]` from the model back to the
|
|
8
|
+
# owning MCP server.
|
|
9
|
+
#
|
|
10
|
+
# Bridge surfaces three MCP capabilities:
|
|
11
|
+
#
|
|
12
|
+
# - **Tools** — every server-advertised tool becomes a Groq function
|
|
13
|
+
# tool, namespaced as `<server>__<tool>`. The LLM calls these
|
|
14
|
+
# exactly like any other function tool; {#call} routes the call to
|
|
15
|
+
# the owning server.
|
|
16
|
+
# - **Resources** — for every server that advertises resources,
|
|
17
|
+
# Bridge injects a synthetic `<server>__read_resource(uri)` tool
|
|
18
|
+
# into {#tools} so the LLM can fetch resource content on demand.
|
|
19
|
+
# {#resources} returns the full inventory if you want to surface
|
|
20
|
+
# it (e.g. in a system-prompt catalogue).
|
|
21
|
+
# - **Prompts** — {#prompts} returns the list of prompt templates
|
|
22
|
+
# advertised by each server. Prompts are typically surfaced to a
|
|
23
|
+
# user (a picker in your UI), not the LLM directly. {#get_prompt}
|
|
24
|
+
# renders one with arguments.
|
|
25
|
+
#
|
|
26
|
+
# Optional capabilities are probed gracefully — a server that
|
|
27
|
+
# responds with `-32601 method not found` to `resources/list` simply
|
|
28
|
+
# contributes no resources.
|
|
29
|
+
#
|
|
30
|
+
# @example Wire a filesystem MCP server into chat.completions
|
|
31
|
+
# fs = GroqRuby::MCP::ServerConfig.new(name: "fs", command: "...")
|
|
32
|
+
# bridge = GroqRuby::MCP::Bridge.new([fs])
|
|
33
|
+
# begin
|
|
34
|
+
# response = groq.chat.completions.create(
|
|
35
|
+
# model: "llama-3.3-70b-versatile",
|
|
36
|
+
# messages: messages,
|
|
37
|
+
# tools: bridge.tools
|
|
38
|
+
# )
|
|
39
|
+
# tool_call = response.choices.first.message.tool_calls&.first
|
|
40
|
+
# result = bridge.call(tool_call["function"]["name"], tool_call["function"]["arguments"]) if tool_call
|
|
41
|
+
# ensure
|
|
42
|
+
# bridge.stop
|
|
43
|
+
# end
|
|
44
|
+
class Bridge
|
|
45
|
+
NAME_SEPARATOR = "__".freeze
|
|
46
|
+
READ_RESOURCE_SUFFIX = "read_resource".freeze
|
|
47
|
+
|
|
48
|
+
# @param configs [Array<ServerConfig>]
|
|
49
|
+
# @param request_timeout [Numeric] forwarded to each {Client}
|
|
50
|
+
def initialize(configs, request_timeout: Client::DEFAULT_REQUEST_TIMEOUT)
|
|
51
|
+
@clients = configs.map { |c| Client.connect(c, request_timeout: request_timeout) }
|
|
52
|
+
@tool_index = build_tool_index
|
|
53
|
+
@resource_index = build_resource_index
|
|
54
|
+
@prompt_index = build_prompt_index
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Tool definitions for `chat.completions.create(tools:)`. Includes
|
|
58
|
+
# every advertised MCP tool plus, for each server with resources,
|
|
59
|
+
# a synthetic `<server>__read_resource(uri)` tool so the LLM can
|
|
60
|
+
# fetch resource content on demand.
|
|
61
|
+
#
|
|
62
|
+
# @return [Array<Hash>]
|
|
63
|
+
def tools
|
|
64
|
+
@tool_index.values.map { |entry| as_groq_tool(entry) } + synthetic_resource_tools
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Array<String>] every namespaced tool name {#tools} surfaces
|
|
68
|
+
def tool_names
|
|
69
|
+
tools.map { |t| t[:function][:name] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Flat inventory of every resource discovered across all servers.
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<Hash>] each entry has keys `:server`, `:resource` (a {Resource})
|
|
75
|
+
def resources
|
|
76
|
+
@resource_index.values.map { |entry| {server: entry[:server_name], resource: entry[:resource]} }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Flat inventory of every prompt template discovered across all servers.
|
|
80
|
+
#
|
|
81
|
+
# @return [Array<Hash>] each entry has keys `:server`, `:namespaced_name`, `:prompt`
|
|
82
|
+
def prompts
|
|
83
|
+
@prompt_index.map do |namespaced, entry|
|
|
84
|
+
{server: entry[:server_name], namespaced_name: namespaced, prompt: entry[:prompt]}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Dispatch a tool call from a Groq response to the owning MCP server.
|
|
89
|
+
# Recognises the synthetic `<server>__read_resource` tool and routes
|
|
90
|
+
# to {#read_resource} instead.
|
|
91
|
+
#
|
|
92
|
+
# @param namespaced_name [String]
|
|
93
|
+
# @param arguments [Hash, String] parsed Hash or the JSON string Groq returns
|
|
94
|
+
# @return [Hash] the MCP server's tool-call result
|
|
95
|
+
# @raise [UnknownToolError] if no connected server advertises this tool
|
|
96
|
+
def call(namespaced_name, arguments)
|
|
97
|
+
parsed_args = parse_args(arguments)
|
|
98
|
+
if (server_name = match_synthetic_resource_tool(namespaced_name))
|
|
99
|
+
uri = parsed_args["uri"] || parsed_args[:uri]
|
|
100
|
+
read_resource(uri, server: server_name)
|
|
101
|
+
else
|
|
102
|
+
dispatch_tool_call(namespaced_name, parsed_args)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Read a resource by URI. When more than one server advertises the
|
|
107
|
+
# same URI, pass `server:` to disambiguate.
|
|
108
|
+
#
|
|
109
|
+
# @param uri [String]
|
|
110
|
+
# @param server [String, nil] explicit server name
|
|
111
|
+
# @return [Hash] the server's `resources/read` result
|
|
112
|
+
# @raise [UnknownToolError] if no server owns the URI
|
|
113
|
+
def read_resource(uri, server: nil)
|
|
114
|
+
candidates = @resource_index.values.select { |e| e[:resource].uri == uri }
|
|
115
|
+
candidates = candidates.select { |e| e[:server_name] == server } if server
|
|
116
|
+
entry = candidates.first or raise UnknownToolError, "no MCP resource for uri #{uri.inspect}"
|
|
117
|
+
entry[:client].resources_read(uri)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Render a prompt template. The name is the namespaced
|
|
121
|
+
# `<server>__<prompt>` form returned by {#prompts}.
|
|
122
|
+
#
|
|
123
|
+
# @param namespaced_name [String]
|
|
124
|
+
# @param arguments [Hash]
|
|
125
|
+
# @return [Hash] the server's `prompts/get` result (`messages` array)
|
|
126
|
+
# @raise [UnknownToolError] if the prompt isn't known
|
|
127
|
+
def get_prompt(namespaced_name, arguments = {})
|
|
128
|
+
entry = @prompt_index[namespaced_name] or raise UnknownToolError, "no MCP prompt named #{namespaced_name.inspect}"
|
|
129
|
+
entry[:client].prompts_get(entry[:prompt].name, arguments)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Stop every underlying MCP server. Idempotent.
|
|
133
|
+
def stop
|
|
134
|
+
@clients.each(&:stop)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def build_tool_index
|
|
140
|
+
@clients.each_with_object({}) do |client, acc|
|
|
141
|
+
server_name = name_for(client)
|
|
142
|
+
tools_for(client).each do |tool|
|
|
143
|
+
acc["#{server_name}#{NAME_SEPARATOR}#{tool.name}"] = {client: client, tool: tool, server_name: server_name}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_resource_index
|
|
149
|
+
@clients.each_with_object({}) do |client, acc|
|
|
150
|
+
server_name = name_for(client)
|
|
151
|
+
resources_for(client).each do |resource|
|
|
152
|
+
key = "#{server_name}#{NAME_SEPARATOR}#{resource.uri}"
|
|
153
|
+
acc[key] = {client: client, resource: resource, server_name: server_name}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_prompt_index
|
|
159
|
+
@clients.each_with_object({}) do |client, acc|
|
|
160
|
+
server_name = name_for(client)
|
|
161
|
+
prompts_for(client).each do |prompt|
|
|
162
|
+
acc["#{server_name}#{NAME_SEPARATOR}#{prompt.name}"] = {client: client, prompt: prompt, server_name: server_name}
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def tools_for(client)
|
|
168
|
+
client.tools_list
|
|
169
|
+
rescue JsonRpcError
|
|
170
|
+
[]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def resources_for(client)
|
|
174
|
+
client.resources_list
|
|
175
|
+
rescue JsonRpcError
|
|
176
|
+
[]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def prompts_for(client)
|
|
180
|
+
client.prompts_list
|
|
181
|
+
rescue JsonRpcError
|
|
182
|
+
[]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def synthetic_resource_tools
|
|
186
|
+
servers_with_resources.map do |server_name|
|
|
187
|
+
{
|
|
188
|
+
type: "function",
|
|
189
|
+
function: {
|
|
190
|
+
name: "#{server_name}#{NAME_SEPARATOR}#{READ_RESOURCE_SUFFIX}",
|
|
191
|
+
description: "Read the contents of a resource hosted by the '#{server_name}' MCP server. The 'uri' argument must be one of the URIs from bridge.resources.",
|
|
192
|
+
parameters: {
|
|
193
|
+
"type" => "object",
|
|
194
|
+
"properties" => {"uri" => {"type" => "string", "description" => "Resource URI to read"}},
|
|
195
|
+
"required" => ["uri"]
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def servers_with_resources
|
|
203
|
+
@resource_index.values.map { |e| e[:server_name] }.uniq
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def match_synthetic_resource_tool(name)
|
|
207
|
+
suffix = "#{NAME_SEPARATOR}#{READ_RESOURCE_SUFFIX}"
|
|
208
|
+
return nil unless name.end_with?(suffix)
|
|
209
|
+
server_name = name.delete_suffix(suffix)
|
|
210
|
+
servers_with_resources.include?(server_name) ? server_name : nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def dispatch_tool_call(namespaced_name, parsed_args)
|
|
214
|
+
entry = @tool_index[namespaced_name] or raise UnknownToolError, "no MCP tool named #{namespaced_name.inspect}"
|
|
215
|
+
entry[:client].tools_call(name: entry[:tool].name, arguments: parsed_args)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def parse_args(arguments)
|
|
219
|
+
return {} if arguments.nil?
|
|
220
|
+
arguments.is_a?(String) ? JSON.parse(arguments) : arguments
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def as_groq_tool(entry)
|
|
224
|
+
{
|
|
225
|
+
type: "function",
|
|
226
|
+
function: {
|
|
227
|
+
name: "#{entry[:server_name]}#{NAME_SEPARATOR}#{entry[:tool].name}",
|
|
228
|
+
description: entry[:tool].description,
|
|
229
|
+
parameters: entry[:tool].input_schema
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def name_for(client)
|
|
235
|
+
client.server_name || "server#{client.object_id}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module GroqRuby
|
|
4
|
+
module MCP
|
|
5
|
+
# Loads MCP server configurations from the same JSON shape Claude
|
|
6
|
+
# Desktop uses (`claude_desktop_config.json`'s `mcpServers` block) and
|
|
7
|
+
# returns a list of {ServerConfig} ready for {Bridge.new}.
|
|
8
|
+
#
|
|
9
|
+
# Supports `${VAR}` interpolation in `args` and `env` values. Lookup
|
|
10
|
+
# order: the server's own `env` block first, then the process's
|
|
11
|
+
# `ENV`. Unresolved references raise so silent misconfigurations
|
|
12
|
+
# don't ship.
|
|
13
|
+
#
|
|
14
|
+
# @example Load from disk
|
|
15
|
+
# configs = GroqRuby::MCP::ClaudeDesktopConfig.load("~/Library/Application Support/Claude/claude_desktop_config.json")
|
|
16
|
+
# bridge = GroqRuby::MCP::Bridge.new(configs)
|
|
17
|
+
#
|
|
18
|
+
# @example Parse an in-memory Hash
|
|
19
|
+
# configs = GroqRuby::MCP::ClaudeDesktopConfig.parse({
|
|
20
|
+
# "mcpServers" => {
|
|
21
|
+
# "spectrum-ferret-staging" => {
|
|
22
|
+
# "command" => "npx",
|
|
23
|
+
# "args" => ["-y", "mcp-remote@latest", "https://...", "--header", "Authorization: Bearer ${SF_PAT}"],
|
|
24
|
+
# "env" => {"SF_PAT" => ENV.fetch("SF_PAT")}
|
|
25
|
+
# }
|
|
26
|
+
# }
|
|
27
|
+
# })
|
|
28
|
+
module ClaudeDesktopConfig
|
|
29
|
+
VAR_PATTERN = /\$\{([A-Z_][A-Z0-9_]*)\}/
|
|
30
|
+
|
|
31
|
+
# Load configurations from a JSON file path.
|
|
32
|
+
#
|
|
33
|
+
# @param path [String] file path
|
|
34
|
+
# @return [Array<ServerConfig>]
|
|
35
|
+
# @raise [Errno::ENOENT] if the file is missing
|
|
36
|
+
# @raise [JSON::ParserError] if the file is not valid JSON
|
|
37
|
+
# @raise [ConfigurationError] if the structure is wrong or `${VAR}` references are unresolvable
|
|
38
|
+
def self.load(path)
|
|
39
|
+
parse(JSON.parse(File.read(File.expand_path(path))))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Parse configurations from an already-decoded Hash (or a JSON
|
|
43
|
+
# string).
|
|
44
|
+
#
|
|
45
|
+
# @param input [Hash, String]
|
|
46
|
+
# @return [Array<ServerConfig>]
|
|
47
|
+
# @raise [ConfigurationError] on structural errors or unresolved variables
|
|
48
|
+
def self.parse(input)
|
|
49
|
+
hash = input.is_a?(String) ? JSON.parse(input) : input
|
|
50
|
+
servers = hash["mcpServers"] || hash[:mcpServers] || {}
|
|
51
|
+
servers.map { |name, entry| build_server_config(name.to_s, entry) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.build_server_config(name, entry)
|
|
55
|
+
command = entry["command"] || entry[:command] or
|
|
56
|
+
raise ConfigurationError, "MCP server #{name.inspect}: missing 'command'"
|
|
57
|
+
raw_args = Array(entry["args"] || entry[:args])
|
|
58
|
+
raw_env = (entry["env"] || entry[:env] || {}).transform_keys(&:to_s)
|
|
59
|
+
|
|
60
|
+
ServerConfig.new(
|
|
61
|
+
name: name,
|
|
62
|
+
command: command,
|
|
63
|
+
args: raw_args.map { |arg| expand_vars(arg, raw_env, server: name) },
|
|
64
|
+
env: raw_env.transform_values { |v| expand_vars(v, raw_env, server: name) }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.expand_vars(value, server_env, server:)
|
|
69
|
+
value.to_s.gsub(VAR_PATTERN) do
|
|
70
|
+
var = Regexp.last_match(1)
|
|
71
|
+
server_env[var] || ENV[var] ||
|
|
72
|
+
raise(ConfigurationError, "MCP server #{server.inspect}: unresolved ${#{var}} (not in env block or process ENV)")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method :build_server_config, :expand_vars
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|