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,171 @@
1
+ require "json"
2
+
3
+ module GroqRuby
4
+ module MCP
5
+ # High-level MCP client. Wraps a {Transport} and exposes the
6
+ # operations a Groq agent typically needs: handshake, list tools,
7
+ # invoke tools, list/read resources.
8
+ #
9
+ # Synchronous on the outside (every public method blocks until the
10
+ # server responds or times out), thread-safe on the inside (the
11
+ # transport's reader thread fulfils a per-id Queue).
12
+ #
13
+ # @example
14
+ # client = GroqRuby::MCP::Client.connect(server_config)
15
+ # tools = client.tools_list
16
+ # result = client.tools_call(name: "read_file", arguments: {path: "/tmp/x"})
17
+ # client.stop
18
+ class Client
19
+ DEFAULT_REQUEST_TIMEOUT = 30.0
20
+
21
+ # Connect to a server via the default stdio transport and complete
22
+ # the initialize handshake. Caller must call {#stop} when done.
23
+ #
24
+ # @param config [ServerConfig]
25
+ # @param request_timeout [Numeric] seconds to wait for any single response
26
+ # @return [Client]
27
+ def self.connect(config, request_timeout: DEFAULT_REQUEST_TIMEOUT)
28
+ transport = Transports::Stdio.spawn(config)
29
+ new(transport, request_timeout: request_timeout).tap(&:initialize_session)
30
+ end
31
+
32
+ # @return [String, nil] the server-reported name, populated after handshake
33
+ attr_reader :server_name
34
+ # @return [String, nil] the server-reported version, populated after handshake
35
+ attr_reader :server_version
36
+ # @return [Hash] the server's advertised capabilities
37
+ attr_reader :server_capabilities
38
+
39
+ # @param transport [Transport]
40
+ # @param request_timeout [Numeric]
41
+ def initialize(transport, request_timeout: DEFAULT_REQUEST_TIMEOUT)
42
+ @transport = transport
43
+ @request_timeout = request_timeout
44
+ @next_id = 0
45
+ @id_mutex = Mutex.new
46
+ @pending = {}
47
+ @pending_mutex = Mutex.new
48
+ @transport.on_message { |msg| handle_message(msg) }
49
+ end
50
+
51
+ # Run the JSON-RPC `initialize` handshake. Called automatically by
52
+ # {.connect}; safe to call again to re-handshake.
53
+ # @return [Hash] the server's response payload
54
+ def initialize_session
55
+ result = request("initialize", {
56
+ protocolVersion: PROTOCOL_VERSION,
57
+ capabilities: {},
58
+ clientInfo: {name: "groq_ruby", version: GroqRuby::VERSION}
59
+ })
60
+ info = result["serverInfo"] || {}
61
+ @server_name = info["name"]
62
+ @server_version = info["version"]
63
+ @server_capabilities = result["capabilities"] || {}
64
+ notify("notifications/initialized")
65
+ result
66
+ end
67
+
68
+ # @return [Array<Tool>] every tool advertised by the server
69
+ def tools_list
70
+ result = request("tools/list", {})
71
+ Array(result["tools"]).map { |h| Tool.from_hash(h) }
72
+ end
73
+
74
+ # Invoke a tool on the server.
75
+ # @param name [String]
76
+ # @param arguments [Hash]
77
+ # @return [Hash] the server's tool-call result (may include `isError`)
78
+ def tools_call(name:, arguments: {})
79
+ request("tools/call", {name: name, arguments: arguments})
80
+ end
81
+
82
+ # @return [Array<Resource>] every resource advertised by the server
83
+ def resources_list
84
+ result = request("resources/list", {})
85
+ Array(result["resources"]).map { |h| Resource.from_hash(h) }
86
+ end
87
+
88
+ # Read a resource by URI.
89
+ # @param uri [String]
90
+ # @return [Hash] `contents` array as returned by the server
91
+ def resources_read(uri)
92
+ request("resources/read", {uri: uri})
93
+ end
94
+
95
+ # @return [Array<Prompt>] every prompt template advertised by the server
96
+ def prompts_list
97
+ result = request("prompts/list", {})
98
+ Array(result["prompts"]).map { |h| Prompt.from_hash(h) }
99
+ end
100
+
101
+ # Render a prompt template by name with the given arguments.
102
+ # @param name [String]
103
+ # @param arguments [Hash]
104
+ # @return [Hash] `messages` array as returned by the server
105
+ def prompts_get(name, arguments = {})
106
+ request("prompts/get", {name: name, arguments: arguments})
107
+ end
108
+
109
+ # Returns true when the handshake reported that the server
110
+ # advertises the given capability (`"tools"`, `"resources"`,
111
+ # `"prompts"`). Useful for probing which Bridge features to enable.
112
+ # @param capability [String]
113
+ # @return [Boolean]
114
+ def supports?(capability)
115
+ @server_capabilities&.key?(capability) || false
116
+ end
117
+
118
+ # Close the underlying transport. Idempotent.
119
+ def stop
120
+ @transport.stop
121
+ end
122
+
123
+ private
124
+
125
+ def request(method, params)
126
+ id = next_id
127
+ queue = Queue.new
128
+ @pending_mutex.synchronize { @pending[id] = queue }
129
+ @transport.send_message(JsonRpc.request(id: id, method: method, params: params))
130
+ await_response(id, queue)
131
+ ensure
132
+ @pending_mutex.synchronize { @pending.delete(id) }
133
+ end
134
+
135
+ def await_response(id, queue)
136
+ message = queue.pop(timeout: @request_timeout)
137
+ raise TimeoutError, "MCP request #{id} (#{@server_name || "server"}) timed out" if message.nil?
138
+ if (err = message["error"])
139
+ raise JsonRpcError.new(code: err["code"], message: err["message"], data: err["data"])
140
+ end
141
+ message["result"] || {}
142
+ end
143
+
144
+ def notify(method, params = {})
145
+ @transport.send_message(JsonRpc.notification(method: method, params: params))
146
+ end
147
+
148
+ def handle_message(message)
149
+ case JsonRpc.classify(message)
150
+ when :response then deliver_response(message)
151
+ when :notification then handle_notification(message)
152
+ # Server-initiated requests and invalid frames are ignored in v1.
153
+ end
154
+ end
155
+
156
+ def deliver_response(message)
157
+ queue = @pending_mutex.synchronize { @pending[message["id"]] }
158
+ queue&.push(message)
159
+ end
160
+
161
+ def handle_notification(_message)
162
+ # v1: no client-side notification handlers. Future: dispatch to
163
+ # registered callbacks for `notifications/tools/list_changed` etc.
164
+ end
165
+
166
+ def next_id
167
+ @id_mutex.synchronize { @next_id += 1 }
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,7 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Base for every MCP-layer failure. Inherits from {GroqRuby::Error} so
4
+ # callers can rescue the whole gem with a single class.
5
+ class Error < GroqRuby::Error; end
6
+ end
7
+ end
@@ -0,0 +1,21 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Server returned a JSON-RPC error response. Carries the protocol code,
4
+ # message, and any extra `data` payload.
5
+ class JsonRpcError < Error
6
+ # @return [Integer] JSON-RPC error code (-32700..-32000 range)
7
+ attr_reader :code
8
+ # @return [Object, nil] optional error data payload
9
+ attr_reader :data
10
+
11
+ # @param code [Integer]
12
+ # @param message [String]
13
+ # @param data [Object, nil]
14
+ def initialize(code:, message:, data: nil)
15
+ @code = code
16
+ @data = data
17
+ super("MCP server error #{code}: #{message}")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,7 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Server sent us bytes that don't parse as JSON-RPC, or are missing a
4
+ # required field.
5
+ class ProtocolError < Error; end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Server died, response never arrived, or shutdown could not be reached
4
+ # in the configured budget.
5
+ class TimeoutError < Error; end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Underlying transport (stdio pipe, HTTP socket) failed.
4
+ class TransportError < Error; end
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Raised by {Bridge} when a tool call references a tool that no
4
+ # connected server advertised.
5
+ class UnknownToolError < Error; end
6
+ end
7
+ end
@@ -0,0 +1,51 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Pure builders for JSON-RPC 2.0 messages. No IO, no state — call
4
+ # these to produce the Hash you'll hand to a transport.
5
+ module JsonRpc
6
+ VERSION = "2.0".freeze
7
+
8
+ # Build a request envelope. Pair `id` with the Hash returned from a
9
+ # subsequent inbound response to correlate.
10
+ #
11
+ # @param id [Integer, String]
12
+ # @param method [String]
13
+ # @param params [Hash, nil]
14
+ # @return [Hash]
15
+ def self.request(id:, method:, params: nil)
16
+ payload = {jsonrpc: VERSION, id: id, method: method}
17
+ payload[:params] = params unless params.nil?
18
+ payload
19
+ end
20
+
21
+ # Build a notification envelope (request without id — no response).
22
+ #
23
+ # @param method [String]
24
+ # @param params [Hash, nil]
25
+ # @return [Hash]
26
+ def self.notification(method:, params: nil)
27
+ payload = {jsonrpc: VERSION, method: method}
28
+ payload[:params] = params unless params.nil?
29
+ payload
30
+ end
31
+
32
+ # Categorise a parsed inbound message. Returns one of:
33
+ # `:response`, `:notification`, `:request`, `:invalid`.
34
+ #
35
+ # @param message [Hash]
36
+ # @return [Symbol]
37
+ def self.classify(message)
38
+ return :invalid unless message.is_a?(Hash) && message["jsonrpc"] == VERSION
39
+ if message.key?("id") && (message.key?("result") || message.key?("error"))
40
+ :response
41
+ elsif message.key?("method") && !message.key?("id")
42
+ :notification
43
+ elsif message.key?("method") && message.key?("id")
44
+ :request
45
+ else
46
+ :invalid
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # An MCP prompt template advertised by a server's `prompts/list`
4
+ # response. Prompts are typically surfaced to *users* as a picker
5
+ # rather than handed to the LLM directly — the user fills in the
6
+ # arguments and the server returns ready-to-send messages via
7
+ # `prompts/get`.
8
+ class Prompt < Data.define(:name, :description, :arguments)
9
+ # @param hash [Hash, nil]
10
+ # @return [Prompt, nil]
11
+ def self.from_hash(hash)
12
+ return nil if hash.nil?
13
+ new(
14
+ name: hash["name"] || hash[:name],
15
+ description: hash["description"] || hash[:description],
16
+ arguments: Array(hash["arguments"] || hash[:arguments])
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # An MCP resource advertised by a server's `resources/list` response.
4
+ class Resource < Data.define(:uri, :name, :description, :mime_type)
5
+ # MCP uses `mimeType` (camelCase); we expose `mime_type`.
6
+ def self.from_hash(hash)
7
+ return nil if hash.nil?
8
+ new(
9
+ uri: hash["uri"] || hash[:uri],
10
+ name: hash["name"] || hash[:name],
11
+ description: hash["description"] || hash[:description],
12
+ mime_type: hash["mimeType"] || hash[:mimeType] || hash["mime_type"] || hash[:mime_type]
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Description of an MCP server to connect to via stdio. Immutable —
4
+ # build one per server, then pass to {Client.connect} or {Bridge.new}.
5
+ #
6
+ # @example Filesystem server
7
+ # ServerConfig.new(
8
+ # name: "fs",
9
+ # command: "npx",
10
+ # args: ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/docs"]
11
+ # )
12
+ class ServerConfig < Data.define(:name, :command, :args, :env)
13
+ # @param name [String] short identifier used to namespace tools in {Bridge}
14
+ # @param command [String] executable to spawn (looked up in PATH)
15
+ # @param args [Array<String>] arguments passed to the executable
16
+ # @param env [Hash{String => String}] extra environment variables for the child process
17
+ def initialize(name:, command:, args: [], env: {})
18
+ super
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # An MCP tool advertised by a server's `tools/list` response.
4
+ # The `input_schema` is a JSON Schema describing the tool's arguments.
5
+ class Tool < Data.define(:name, :description, :input_schema)
6
+ extend Models::ModelFactory
7
+
8
+ coerce :input_schema, with: ->(v) { v || {} }
9
+
10
+ # Override the factory to map MCP's camelCase `inputSchema` field to
11
+ # our snake_case attribute.
12
+ def self.from_hash(hash)
13
+ return nil if hash.nil?
14
+ new(
15
+ name: hash["name"] || hash[:name],
16
+ description: hash["description"] || hash[:description],
17
+ input_schema: hash["inputSchema"] || hash[:inputSchema] || hash["input_schema"] || hash[:input_schema] || {}
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Interface every MCP transport must satisfy. Synchronous send,
4
+ # asynchronous receive via a single registered callback. Implementations
5
+ # own their own background reader thread/loop.
6
+ #
7
+ # @abstract
8
+ module Transport
9
+ # @param message [Hash] JSON-RPC envelope to deliver to the server
10
+ # @return [void]
11
+ def send_message(message)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ # Register the callback invoked for every inbound message.
16
+ # Replaces any previous callback. Called from the transport's
17
+ # reader thread, so callbacks must be thread-safe.
18
+ #
19
+ # @yieldparam message [Hash] decoded JSON-RPC envelope
20
+ # @return [void]
21
+ def on_message(&block)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ # Tear down the connection. Idempotent.
26
+ # @return [void]
27
+ def stop
28
+ raise NotImplementedError
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,100 @@
1
+ require "open3"
2
+ require "json"
3
+
4
+ module GroqRuby
5
+ module MCP
6
+ module Transports
7
+ # Stdio transport — spawns the configured executable, talks
8
+ # newline-delimited JSON over its stdin/stdout, drains stderr to
9
+ # /dev/null. A background reader thread fans inbound messages out
10
+ # to the callback registered via {#on_message}.
11
+ #
12
+ # @example
13
+ # transport = Stdio.spawn(ServerConfig.new(name: "fs", command: "..."))
14
+ # transport.on_message { |msg| puts msg.inspect }
15
+ # transport.send_message({jsonrpc: "2.0", id: 1, method: "ping"})
16
+ # ...
17
+ # transport.stop
18
+ class Stdio
19
+ include Transport
20
+
21
+ # Build and start a Stdio transport for the given server config.
22
+ # @param config [ServerConfig]
23
+ # @return [Stdio] a started transport ready to {#send_message}
24
+ # @raise [TransportError] if the child process cannot be spawned
25
+ def self.spawn(config)
26
+ new(config).tap(&:start)
27
+ end
28
+
29
+ # @param config [ServerConfig]
30
+ def initialize(config)
31
+ @config = config
32
+ @on_message = nil
33
+ @write_mutex = Mutex.new
34
+ @stopped = false
35
+ end
36
+
37
+ # Spawn the child process and start the reader thread.
38
+ # @return [self]
39
+ def start
40
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(@config.env, @config.command, *@config.args)
41
+ @stderr_thread = Thread.new { drain(@stderr) }
42
+ @reader_thread = Thread.new { read_loop }
43
+ self
44
+ rescue SystemCallError => e
45
+ raise TransportError, "could not spawn MCP server #{@config.name.inspect}: #{e.message}"
46
+ end
47
+
48
+ def send_message(message)
49
+ line = JSON.generate(message)
50
+ @write_mutex.synchronize do
51
+ @stdin.puts(line)
52
+ @stdin.flush
53
+ end
54
+ rescue IOError, Errno::EPIPE => e
55
+ raise TransportError, "stdin closed for MCP server #{@config.name.inspect}: #{e.message}"
56
+ end
57
+
58
+ def on_message(&block)
59
+ @on_message = block
60
+ end
61
+
62
+ def stop
63
+ return if @stopped
64
+ @stopped = true
65
+ @reader_thread&.kill
66
+ @stderr_thread&.kill
67
+ [@stdin, @stdout, @stderr].each { |io| io&.close }
68
+ @wait_thr&.kill if @wait_thr&.alive?
69
+ end
70
+
71
+ private
72
+
73
+ def read_loop
74
+ while (line = @stdout.gets)
75
+ stripped = line.strip
76
+ next if stripped.empty?
77
+ dispatch(stripped)
78
+ end
79
+ rescue IOError
80
+ # Pipe closed — normal during shutdown.
81
+ end
82
+
83
+ def dispatch(line)
84
+ message = JSON.parse(line)
85
+ @on_message&.call(message)
86
+ rescue JSON::ParserError
87
+ # Malformed line from server; surface as a synthetic protocol
88
+ # error so the Client can decide what to do.
89
+ @on_message&.call({"jsonrpc" => "2.0", "_invalid" => line})
90
+ end
91
+
92
+ def drain(io)
93
+ io.each_line { |_| }
94
+ rescue IOError
95
+ # Closed during shutdown — fine.
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,25 @@
1
+ module GroqRuby
2
+ # Model Context Protocol (MCP) client. Lets a Groq agent talk to MCP
3
+ # servers — local processes (stdio transport) that expose tools and
4
+ # resources via JSON-RPC 2.0.
5
+ #
6
+ # Two ways in:
7
+ #
8
+ # 1. Direct {Client} — connect to one server, call tools yourself:
9
+ #
10
+ # config = GroqRuby::MCP::ServerConfig.new(name: "fs", command: "...", args: [...])
11
+ # client = GroqRuby::MCP::Client.connect(config)
12
+ # tools = client.tools_list
13
+ # result = client.tools_call(name: "read_file", arguments: {path: "/foo"})
14
+ # client.stop
15
+ #
16
+ # 2. {Bridge} — wire many servers into Groq's `chat.completions(tools:)`
17
+ # automatically and route tool_calls back to the owning server.
18
+ #
19
+ # bridge = GroqRuby::MCP::Bridge.new([fs_config, weather_config])
20
+ # groq.chat.completions.create(..., tools: bridge.tools)
21
+ # # then bridge.call(tool_call.function.name, JSON.parse(tool_call.function.arguments))
22
+ module MCP
23
+ PROTOCOL_VERSION = "2024-11-05".freeze
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ module GroqRuby
2
+ module Models
3
+ # Result of `client.audio.transcriptions.create`. The shape varies with
4
+ # `response_format` (`json`, `verbose_json`, etc.); we expose both the
5
+ # primary `text` field and the full payload for richer formats.
6
+ class Transcription < Data.define(:text, :language, :duration, :segments, :words, :x_groq)
7
+ extend ModelFactory
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ module GroqRuby
2
+ module Models
3
+ # Result of `client.audio.translations.create`.
4
+ class Translation < Data.define(:text, :language, :duration, :segments, :x_groq)
5
+ extend ModelFactory
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,16 @@
1
+ module GroqRuby
2
+ module Models
3
+ # A single batch job descriptor returned by the batches endpoints.
4
+ class Batch < Data.define(
5
+ :id, :object, :endpoint, :errors, :input_file_id, :completion_window,
6
+ :status, :output_file_id, :error_file_id,
7
+ :created_at, :in_progress_at, :expires_at, :finalizing_at,
8
+ :completed_at, :failed_at, :expired_at, :cancelling_at, :cancelled_at,
9
+ :request_counts, :metadata
10
+ )
11
+ extend ModelFactory
12
+
13
+ coerce :request_counts, with: ->(h) { h && BatchRequestCounts.from_hash(h) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,10 @@
1
+ module GroqRuby
2
+ module Models
3
+ # Wrapper for a `batches.list` response.
4
+ class BatchList < Data.define(:data, :object, :first_id, :last_id, :has_more)
5
+ extend ModelFactory
6
+
7
+ coerce :data, with: ->(arr) { Array(arr).map { |h| Batch.from_hash(h) } }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ module GroqRuby
2
+ module Models
3
+ # Per-batch request count summary.
4
+ class BatchRequestCounts < Data.define(:total, :completed, :failed)
5
+ extend ModelFactory
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,14 @@
1
+ module GroqRuby
2
+ module Models
3
+ # Full response from `client.chat.completions.create` (non-streaming).
4
+ class ChatCompletion < Data.define(
5
+ :id, :object, :created, :model, :choices, :usage,
6
+ :system_fingerprint, :x_groq
7
+ )
8
+ extend ModelFactory
9
+
10
+ coerce :choices, with: ->(arr) { Array(arr).map { |h| ChatCompletionChoice.from_hash(h) } }
11
+ coerce :usage, with: ->(h) { Usage.from_hash(h) }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ module GroqRuby
2
+ module Models
3
+ # A single completion choice in a {ChatCompletion} response.
4
+ class ChatCompletionChoice < Data.define(:index, :message, :finish_reason, :logprobs)
5
+ extend ModelFactory
6
+
7
+ coerce :message, with: ->(h) { ChatCompletionMessage.from_hash(h) }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ module GroqRuby
2
+ module Models
3
+ # A single Server-Sent Event yielded while streaming a chat completion.
4
+ class ChatCompletionChunk < Data.define(
5
+ :id, :object, :created, :model, :choices, :usage, :system_fingerprint, :x_groq
6
+ )
7
+ extend ModelFactory
8
+
9
+ coerce :choices, with: ->(arr) { Array(arr).map { |h| ChatCompletionChunkChoice.from_hash(h) } }
10
+ coerce :usage, with: ->(h) { h && Usage.from_hash(h) }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ module GroqRuby
2
+ module Models
3
+ # One choice inside a streaming chunk.
4
+ class ChatCompletionChunkChoice < Data.define(:index, :delta, :finish_reason, :logprobs)
5
+ extend ModelFactory
6
+
7
+ coerce :delta, with: ->(h) { ChatCompletionDelta.from_hash(h) }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ module GroqRuby
2
+ module Models
3
+ # Delta payload yielded inside a streaming chat completion chunk.
4
+ class ChatCompletionDelta < Data.define(:role, :content, :tool_calls, :function_call, :refusal, :reasoning)
5
+ extend ModelFactory
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ module GroqRuby
2
+ module Models
3
+ # An assistant message inside a {ChatCompletion} choice.
4
+ class ChatCompletionMessage < Data.define(
5
+ :role, :content, :tool_calls, :function_call, :refusal, :reasoning
6
+ )
7
+ extend ModelFactory
8
+ end
9
+ end
10
+ end