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,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,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,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,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,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
|