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.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +57 -0
  4. data/CLAUDE.md +103 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +495 -0
  7. data/Rakefile +11 -0
  8. data/examples/README.md +39 -0
  9. data/examples/batch.rb +29 -0
  10. data/examples/chat_completion.rb +24 -0
  11. data/examples/chat_completion_stop.rb +19 -0
  12. data/examples/chat_completion_streaming.rb +23 -0
  13. data/examples/embedding.rb +20 -0
  14. data/examples/error_handling.rb +27 -0
  15. data/examples/file_upload.rb +23 -0
  16. data/examples/mcp_agent.rb +63 -0
  17. data/examples/mcp_chat_with_tools.rb +103 -0
  18. data/examples/mcp_resources_and_prompts.rb +89 -0
  19. data/examples/models_list.rb +16 -0
  20. data/examples/speech.rb +23 -0
  21. data/examples/transcription.rb +23 -0
  22. data/examples/translation.rb +22 -0
  23. data/lib/groq_ruby/client.rb +69 -0
  24. data/lib/groq_ruby/configuration.rb +62 -0
  25. data/lib/groq_ruby/error_mapper.rb +37 -0
  26. data/lib/groq_ruby/errors/api_connection_error.rb +8 -0
  27. data/lib/groq_ruby/errors/api_error.rb +14 -0
  28. data/lib/groq_ruby/errors/api_response_error.rb +5 -0
  29. data/lib/groq_ruby/errors/api_status_error.rb +23 -0
  30. data/lib/groq_ruby/errors/api_timeout_error.rb +8 -0
  31. data/lib/groq_ruby/errors/authentication_error.rb +4 -0
  32. data/lib/groq_ruby/errors/bad_request_error.rb +4 -0
  33. data/lib/groq_ruby/errors/configuration_error.rb +4 -0
  34. data/lib/groq_ruby/errors/conflict_error.rb +4 -0
  35. data/lib/groq_ruby/errors/error.rb +5 -0
  36. data/lib/groq_ruby/errors/internal_server_error.rb +4 -0
  37. data/lib/groq_ruby/errors/not_found_error.rb +4 -0
  38. data/lib/groq_ruby/errors/parameter_error.rb +13 -0
  39. data/lib/groq_ruby/errors/permission_denied_error.rb +4 -0
  40. data/lib/groq_ruby/errors/rate_limit_error.rb +4 -0
  41. data/lib/groq_ruby/errors/unprocessable_entity_error.rb +4 -0
  42. data/lib/groq_ruby/mcp/bridge.rb +239 -0
  43. data/lib/groq_ruby/mcp/claude_desktop_config.rb +79 -0
  44. data/lib/groq_ruby/mcp/client.rb +171 -0
  45. data/lib/groq_ruby/mcp/errors/error.rb +7 -0
  46. data/lib/groq_ruby/mcp/errors/json_rpc_error.rb +21 -0
  47. data/lib/groq_ruby/mcp/errors/protocol_error.rb +7 -0
  48. data/lib/groq_ruby/mcp/errors/timeout_error.rb +7 -0
  49. data/lib/groq_ruby/mcp/errors/transport_error.rb +6 -0
  50. data/lib/groq_ruby/mcp/errors/unknown_tool_error.rb +7 -0
  51. data/lib/groq_ruby/mcp/json_rpc.rb +51 -0
  52. data/lib/groq_ruby/mcp/prompt.rb +21 -0
  53. data/lib/groq_ruby/mcp/resource.rb +17 -0
  54. data/lib/groq_ruby/mcp/server_config.rb +22 -0
  55. data/lib/groq_ruby/mcp/tool.rb +22 -0
  56. data/lib/groq_ruby/mcp/transport.rb +32 -0
  57. data/lib/groq_ruby/mcp/transports/stdio.rb +100 -0
  58. data/lib/groq_ruby/mcp.rb +25 -0
  59. data/lib/groq_ruby/models/audio/transcription.rb +10 -0
  60. data/lib/groq_ruby/models/audio/translation.rb +8 -0
  61. data/lib/groq_ruby/models/batches/batch.rb +16 -0
  62. data/lib/groq_ruby/models/batches/batch_list.rb +10 -0
  63. data/lib/groq_ruby/models/batches/batch_request_counts.rb +8 -0
  64. data/lib/groq_ruby/models/chat/chat_completion.rb +14 -0
  65. data/lib/groq_ruby/models/chat/chat_completion_choice.rb +10 -0
  66. data/lib/groq_ruby/models/chat/chat_completion_chunk.rb +13 -0
  67. data/lib/groq_ruby/models/chat/chat_completion_chunk_choice.rb +10 -0
  68. data/lib/groq_ruby/models/chat/chat_completion_delta.rb +8 -0
  69. data/lib/groq_ruby/models/chat/chat_completion_message.rb +10 -0
  70. data/lib/groq_ruby/models/embeddings/create_embedding_response.rb +11 -0
  71. data/lib/groq_ruby/models/embeddings/embedding.rb +8 -0
  72. data/lib/groq_ruby/models/embeddings/embedding_usage.rb +8 -0
  73. data/lib/groq_ruby/models/files/file_deleted.rb +8 -0
  74. data/lib/groq_ruby/models/files/file_list.rb +10 -0
  75. data/lib/groq_ruby/models/files/file_object.rb +8 -0
  76. data/lib/groq_ruby/models/model.rb +8 -0
  77. data/lib/groq_ruby/models/model_deleted.rb +8 -0
  78. data/lib/groq_ruby/models/model_factory.rb +31 -0
  79. data/lib/groq_ruby/models/model_list.rb +10 -0
  80. data/lib/groq_ruby/models/usage.rb +11 -0
  81. data/lib/groq_ruby/multipart.rb +84 -0
  82. data/lib/groq_ruby/request.rb +13 -0
  83. data/lib/groq_ruby/resources/audio/speech.rb +32 -0
  84. data/lib/groq_ruby/resources/audio/transcriptions.rb +48 -0
  85. data/lib/groq_ruby/resources/audio/translations.rb +45 -0
  86. data/lib/groq_ruby/resources/audio.rb +26 -0
  87. data/lib/groq_ruby/resources/base.rb +33 -0
  88. data/lib/groq_ruby/resources/batches.rb +44 -0
  89. data/lib/groq_ruby/resources/chat/completions.rb +94 -0
  90. data/lib/groq_ruby/resources/chat.rb +16 -0
  91. data/lib/groq_ruby/resources/embeddings.rb +28 -0
  92. data/lib/groq_ruby/resources/files.rb +55 -0
  93. data/lib/groq_ruby/resources/models.rb +35 -0
  94. data/lib/groq_ruby/response.rb +9 -0
  95. data/lib/groq_ruby/streaming/chunk_stream.rb +58 -0
  96. data/lib/groq_ruby/streaming/event_parser.rb +23 -0
  97. data/lib/groq_ruby/transport.rb +169 -0
  98. data/lib/groq_ruby/version.rb +5 -0
  99. data/lib/groq_ruby.rb +36 -0
  100. data/lib/tasks/gem.rake +5 -0
  101. data/lib/tasks/lint/all.rake +11 -0
  102. data/lib/tasks/lint/rubocop.rake +15 -0
  103. data/lib/tasks/security.rake +11 -0
  104. data/lib/tasks/types.rake +11 -0
  105. data/sig/groq_ruby.rbs +191 -0
  106. data/sig/zeitwerk.rbs +13 -0
  107. data.tar.gz.sig +0 -0
  108. metadata +237 -0
  109. 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,8 @@
1
+ module GroqRuby
2
+ # Network failed before a response was received (DNS, refused, reset...).
3
+ class APIConnectionError < APIError
4
+ def initialize(message = "Connection error.", request: nil)
5
+ super
6
+ end
7
+ end
8
+ 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,5 @@
1
+ module GroqRuby
2
+ # Raised when the API returned an unexpected payload (e.g. non-JSON body
3
+ # for a JSON endpoint) and we cannot map it onto a response model.
4
+ class APIResponseError < APIError; end
5
+ 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,8 @@
1
+ module GroqRuby
2
+ # Connection or read timed out.
3
+ class APITimeoutError < APIConnectionError
4
+ def initialize(message = "Request timed out.", request: nil)
5
+ super
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,4 @@
1
+ module GroqRuby
2
+ # HTTP 401 — invalid or missing API key.
3
+ class AuthenticationError < APIStatusError; end
4
+ end
@@ -0,0 +1,4 @@
1
+ module GroqRuby
2
+ # HTTP 400 — the request was malformed or rejected by the API.
3
+ class BadRequestError < APIStatusError; end
4
+ end
@@ -0,0 +1,4 @@
1
+ module GroqRuby
2
+ # Raised when client configuration is missing or invalid (e.g. no api_key).
3
+ class ConfigurationError < Error; end
4
+ end
@@ -0,0 +1,4 @@
1
+ module GroqRuby
2
+ # HTTP 409 — request conflicts with the current resource state.
3
+ class ConflictError < APIStatusError; end
4
+ end
@@ -0,0 +1,5 @@
1
+ module GroqRuby
2
+ # Base for every error raised by this gem. Callers can rescue this single
3
+ # class and degrade gracefully without enumerating subclasses.
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,4 @@
1
+ module GroqRuby
2
+ # HTTP 5xx — Groq is unhappy or unreachable from its side.
3
+ class InternalServerError < APIStatusError; end
4
+ end
@@ -0,0 +1,4 @@
1
+ module GroqRuby
2
+ # HTTP 404 — referenced resource (model, file, batch) does not exist.
3
+ class NotFoundError < APIStatusError; end
4
+ 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,4 @@
1
+ module GroqRuby
2
+ # HTTP 403 — the API key cannot perform this action.
3
+ class PermissionDeniedError < APIStatusError; end
4
+ end
@@ -0,0 +1,4 @@
1
+ module GroqRuby
2
+ # HTTP 429 — too many requests; back off and retry.
3
+ class RateLimitError < APIStatusError; end
4
+ end
@@ -0,0 +1,4 @@
1
+ module GroqRuby
2
+ # HTTP 422 — well-formed request but semantically invalid.
3
+ class UnprocessableEntityError < APIStatusError; end
4
+ 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