groq_ruby 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e37b875bee74a1cd5660dd886ae8aa670b964cd6f75822abf9d66c81060e6b81
4
- data.tar.gz: a68e3933724ff8972efef22ab22ea3e5ceb1a657e29451008770c2f63f479068
3
+ metadata.gz: 9f61d7fe590a9389b584b1d6f6a30551809aa980204401ec03189c7fb468c4d9
4
+ data.tar.gz: d08fa9af8238213284d457d3b234aa706f1aba45ca99c22edd5b21d61fbeef13
5
5
  SHA512:
6
- metadata.gz: a97a4e32dae7e9d313da6d6f4dd75e570218f55296e2daf06c071c11230e90e75832ec646f5e6e3f4cdb92e8eaa687235bd9541d20b2e96c7d3a10b6bf9bdea1
7
- data.tar.gz: a83b33d635b1b06ff1e157a21d3c9c317ca4bbb0d0793eb575277f1cf1b6750f0fdb8e28540d8d961e123995a223e99d380d15020f3d33967ecdfa372cac9cfe
6
+ metadata.gz: c7cfbecd253877f1a17b0e688d2b48b4e9a96e04eda485843e4403d254b685643222d29405bae9e9b5d938a887ee301de958ffd4454557d9d766411c27c721fe
7
+ data.tar.gz: 71c6dad9a48dbc928f31feb545038e4df06cb8e44f7f734a62b3d791ae8a5edf1c9ce0c828af9b9c03ade06848137587c7b40e51d9938f69b961ad67ced1ff5e
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -6,6 +6,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0]
10
+
11
+ Adds HTTP-based MCP transport support and per-protocol-version selection,
12
+ so Groq agents can talk to remote MCP servers (HTTP/HTTPS) in addition
13
+ to local stdio-launched ones.
14
+
15
+ ### Added — MCP
16
+
17
+ - `GroqRuby::MCP::HttpServerConfig` — describes a remote MCP server
18
+ (URL + extra headers, e.g. for bearer auth). Sibling of `ServerConfig`.
19
+ - `GroqRuby::MCP::Transports::HttpStreamable` — implements the MCP
20
+ HTTP Streamable transport (single endpoint POST with JSON or SSE
21
+ response, `Mcp-Session-Id` header captured and echoed,
22
+ `MCP-Protocol-Version` header on every request, best-effort `DELETE`
23
+ on `stop`). Each request runs on a background thread; `stop` joins
24
+ all in-flight threads before terminating the session.
25
+ - `GroqRuby::MCP::Protocol` — registry of versioned protocol objects
26
+ with `Protocol.for(version)`, `Protocol.default`. Each protocol
27
+ object owns its `initialize` payload shape and version string.
28
+ - `GroqRuby::MCP::Protocol::V2024_11_05` — original public release
29
+ protocol (the pre-existing default for stdio servers).
30
+ - `GroqRuby::MCP::Protocol::V2025_11_25` — capability stub for the
31
+ newest spec; future PRs will add the tasks API surface.
32
+ - `Client.new` and `Client.connect` accept a `protocol:` kwarg.
33
+ `Client#protocol` exposes the active protocol object.
34
+ - `Client.connect` now dispatches the transport by config type:
35
+ `ServerConfig` → stdio + 2024-11-05, `HttpServerConfig` → HTTP
36
+ Streamable + 2025-11-25. Both transport choice and protocol are
37
+ overridable.
38
+ - `Bridge` accepts mixed `ServerConfig` and `HttpServerConfig` arrays
39
+ transparently — no API change required.
40
+ - New example: `examples/mcp_http_remote.rb`.
41
+
42
+ ### Changed
43
+
44
+ - `Client.connect` signature: gained `protocol:` kwarg (default `nil`,
45
+ resolved from config type). Existing callers passing only a stdio
46
+ `ServerConfig` are unaffected.
47
+
48
+ ### Notes
49
+
50
+ - HTTP transport currently lacks the long-lived `GET` SSE channel for
51
+ server-initiated notifications without an in-flight request; that
52
+ arrives with the tasks-API follow-up. Server-initiated frames
53
+ interleaved on a `POST` SSE response are dispatched normally.
54
+ - Sticking with `MCP::PROTOCOL_VERSION = "2024-11-05"` as the
55
+ top-level constant for backward compatibility; per-instance protocol
56
+ selection is the forward-compatible API.
57
+
9
58
  ## [0.1.0]
10
59
 
11
60
  Initial release. Idiomatic Ruby client for the Groq API, mirroring the
data/CLAUDE.md CHANGED
@@ -52,6 +52,16 @@ explicit discussion:
52
52
  feature. The bridge speaks JSON-RPC 2.0 to MCP servers and converts
53
53
  their tools to OpenAI-shaped function tools. Don't suggest adding a
54
54
  request-side `mcp_servers:` parameter — Groq's API doesn't accept one.
55
+ - **MCP transports are pluggable; protocol versions are pluggable.**
56
+ `Transports::Stdio` (child process) and `Transports::HttpStreamable`
57
+ (single HTTPS endpoint) are siblings. `Protocol::V2024_11_05` and
58
+ `Protocol::V2025_11_25` are sibling protocol objects, each owning the
59
+ `initialize` payload shape and the version string. `Client.connect`
60
+ dispatches both transport and default protocol off the config class
61
+ (`ServerConfig` → stdio + V2024_11_05, `HttpServerConfig` → HTTP +
62
+ V2025_11_25); both are overridable via the `protocol:` kwarg. Don't
63
+ consolidate transports into the Client or push protocol-version
64
+ choice up to a global constant — per-instance is the design.
55
65
 
56
66
  ## Layout
57
67
 
@@ -62,7 +72,9 @@ explicit discussion:
62
72
  - `lib/groq_ruby/resources/{chat,audio}/*.rb` and `lib/groq_ruby/resources/*.rb` — one class per API surface.
63
73
  - `lib/groq_ruby/models/{chat,audio,embeddings,files,batches}/*.rb` — collapsed → `GroqRuby::Models::*`.
64
74
  - `lib/groq_ruby/streaming/*.rb` — SSE adapter (`event_stream_parser`) + `ChunkStream` enumerable.
65
- - `lib/groq_ruby/mcp/*.rb` — MCP client, bridge, transports, error families (collapsed). `MCP::PROTOCOL_VERSION` lives in `mcp.rb`.
75
+ - `lib/groq_ruby/mcp/*.rb` — MCP client, bridge, transports, protocol objects, error families (collapsed). `MCP::PROTOCOL_VERSION` lives in `mcp.rb` (kept for backward compat — points at the V2024_11_05 wire string).
76
+ - `lib/groq_ruby/mcp/transports/{stdio,http_streamable}.rb` — one transport per file.
77
+ - `lib/groq_ruby/mcp/protocol/{v2024_11_05,v2025_11_25}.rb` — versioned protocol objects under `MCP::Protocol::*`. Class names contain underscores deliberately (mirroring the wire format) and carry an inline `rubocop:disable` for `Naming/ClassAndModuleCamelCase`.
66
78
  - `sig/groq_ruby.rbs` — public-API RBS sigs. Keep in sync when adding public API.
67
79
  - `test/**/test_*.rb` — Minitest. Real network calls are forbidden — every HTTP test stubs via WebMock; every MCP test uses `test/support/fake_mcp_transport.rb`.
68
80
  - `examples/*.rb` — runnable scripts. Each starts with `require "bundler/setup"; require "groq_ruby"`.
@@ -99,5 +111,16 @@ explicit discussion:
99
111
  `Bridge` if the LLM should be able to use it through chat
100
112
  completions. Probe optional capabilities gracefully (catch
101
113
  `JsonRpcError` / `-32601`).
114
+ - New MCP transport → add a class under
115
+ `lib/groq_ruby/mcp/transports/` that `include Transport` (so
116
+ `send_message`/`on_message`/`stop` are mandatory) + a sibling config
117
+ class (`<Name>ServerConfig`) at `lib/groq_ruby/mcp/`. Wire the
118
+ dispatch in `Client.build_transport` / `Client.default_protocol_for`.
119
+ - New MCP protocol version → add a class under
120
+ `lib/groq_ruby/mcp/protocol/` (filename uses the wire format, e.g.
121
+ `v2025_11_25.rb` → `Protocol::V2025_11_25`) and register it in
122
+ `Protocol.registry`. Add a Zeitwerk inflection in `lib/groq_ruby.rb`.
123
+ Carry the `rubocop:disable Naming/ClassAndModuleCamelCase` inline on
124
+ the class.
102
125
  - New public method → add a YARD `@param`/`@return`/`@raise` and
103
126
  update `sig/groq_ruby.rbs`.
data/README.md CHANGED
@@ -8,7 +8,8 @@ shape: typed response objects, single-purpose resource classes, internal
8
8
  `dry-monads` `Result` pipelines, and request validation via
9
9
  `dry-schema`. Streaming chat completions are supported via Server-Sent
10
10
  Events. A built-in [MCP](https://modelcontextprotocol.io) client lets you
11
- wire one or more MCP servers into a Groq chat completion as tools.
11
+ wire one or more MCP servers local stdio processes or remote HTTP
12
+ endpoints — into a Groq chat completion as tools.
12
13
 
13
14
  This gem is **not** an official Groq product. The wire protocol it
14
15
  implements and the API surface it mirrors come from the publicly
@@ -240,8 +241,9 @@ client.batches.cancel(batch.id)
240
241
 
241
242
  ## MCP — Model Context Protocol
242
243
 
243
- `groq_ruby` ships with a stdio MCP client, so a Groq agent can use tools
244
- exposed by any MCP-compatible server (filesystem, web, custom tooling).
244
+ `groq_ruby` ships with stdio and HTTP Streamable MCP clients, so a Groq
245
+ agent can use tools exposed by any MCP-compatible server local
246
+ processes (filesystem, custom tooling) or remote HTTPS endpoints.
245
247
  Coverage matches what host applications like Claude Desktop surface:
246
248
 
247
249
  | MCP capability | Coverage |
@@ -257,40 +259,67 @@ empty for that server.
257
259
 
258
260
  ### Configuring servers
259
261
 
260
- Build `ServerConfig` directly, or load from the same JSON shape Claude
261
- Desktop uses (`mcpServers` block). The Claude Desktop adapter expands
262
- `${VAR}` references against each server's `env` block first, then the
263
- process's `ENV`, and raises on unresolved references.
262
+ Two transports are available pick by config class:
263
+
264
+ - `ServerConfig` **stdio**, child process launched locally.
265
+ - `HttpServerConfig` **HTTP Streamable**, single HTTPS endpoint
266
+ (the transport defined for MCP 2025-03-26+ and required by 2025-11-25).
264
267
 
265
268
  ```ruby
266
- # (a) direct
267
- config = GroqRuby::MCP::ServerConfig.new(
269
+ # (a) stdio — local process
270
+ fs = GroqRuby::MCP::ServerConfig.new(
268
271
  name: "fs",
269
272
  command: "npx",
270
273
  args: ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/docs"]
271
274
  )
272
275
 
273
- # (b) load Claude Desktop JSON
276
+ # (b) HTTP remote endpoint, headers carry auth
277
+ remote = GroqRuby::MCP::HttpServerConfig.new(
278
+ name: "spectrum-ferret",
279
+ url: "https://mcp-staging.spectrumferret.com/mcp",
280
+ headers: {"Authorization" => "Bearer #{ENV.fetch("SF_TOKEN")}"}
281
+ )
282
+
283
+ bridge = GroqRuby::MCP::Bridge.new([fs, remote]) # mix freely
284
+ ```
285
+
286
+ Or load the same JSON shape Claude Desktop uses (`mcpServers` block) for
287
+ stdio servers — the adapter expands `${VAR}` references against each
288
+ server's `env` block first, then the process's `ENV`, and raises on
289
+ unresolved references.
290
+
291
+ ```ruby
274
292
  configs = GroqRuby::MCP::ClaudeDesktopConfig.load(
275
293
  "~/Library/Application Support/Claude/claude_desktop_config.json"
276
294
  )
277
295
  bridge = GroqRuby::MCP::Bridge.new(configs)
296
+ ```
278
297
 
279
- # (c) parse an in-memory hash (e.g. for a private server with a PAT)
280
- configs = GroqRuby::MCP::ClaudeDesktopConfig.parse({
281
- "mcpServers" => {
282
- "spectrum-ferret-staging" => {
283
- "command" => "npx",
284
- "args" => [
285
- "-y", "mcp-remote@latest", "https://mcp-staging.spectrumferret.com",
286
- "--header", "Authorization: Bearer ${SF_PAT}"
287
- ],
288
- "env" => {"SF_PAT" => ENV.fetch("SF_PAT")}
289
- }
290
- }
291
- })
298
+ #### Picking a protocol version
299
+
300
+ Each `Client` carries a `Protocol` object that selects an MCP spec year.
301
+ Defaults are sensible for each transport:
302
+
303
+ | Transport | Default protocol |
304
+ |--------------------|-------------------|
305
+ | `ServerConfig` | `Protocol::V2024_11_05` |
306
+ | `HttpServerConfig` | `Protocol::V2025_11_25` |
307
+
308
+ Override per-client when you need to:
309
+
310
+ ```ruby
311
+ mcp = GroqRuby::MCP::Client.connect(
312
+ config,
313
+ protocol: GroqRuby::MCP::Protocol::V2024_11_05.new
314
+ )
315
+
316
+ mcp.protocol.version # => "2024-11-05"
292
317
  ```
293
318
 
319
+ `GroqRuby::MCP::Protocol.for("2025-11-25")` returns the matching class
320
+ (or `nil` for unknown versions); `Protocol.default` returns the
321
+ conservative default (`V2024_11_05`).
322
+
294
323
  ### Direct usage
295
324
 
296
325
  ```ruby
@@ -321,7 +350,7 @@ mcp.stop
321
350
 
322
351
  `Bridge` does three things at construction:
323
352
 
324
- 1. spawns each `ServerConfig`'s child process via stdio,
353
+ 1. opens a transport for each config (stdio child process for `ServerConfig`, HTTPS connection for `HttpServerConfig`),
325
354
  2. runs the MCP `initialize` handshake and asks each server for its tool list,
326
355
  3. indexes those tools by namespaced name `<server>__<tool>` so collisions across servers are impossible.
327
356
 
@@ -443,7 +472,7 @@ Hierarchy:
443
472
  | `GroqRuby::InternalServerError` | 5xx |
444
473
  | `GroqRuby::APIResponseError` | API returned an unexpected payload |
445
474
  | `GroqRuby::MCP::Error` | Base for any MCP-layer failure |
446
- | `GroqRuby::MCP::TransportError` | Stdio pipe broke or process exited |
475
+ | `GroqRuby::MCP::TransportError` | Stdio pipe broke / HTTP request failed / non-2xx |
447
476
  | `GroqRuby::MCP::TimeoutError` | MCP request timed out |
448
477
  | `GroqRuby::MCP::ProtocolError` | Server sent malformed JSON-RPC |
449
478
  | `GroqRuby::MCP::JsonRpcError` | Server returned a JSON-RPC `error` |
@@ -469,6 +498,9 @@ The python SDK has a few features that aren't in `groq_ruby` v1:
469
498
  - `with_raw_response` / `with_streaming_response` accessors (responses are always parsed into typed models).
470
499
  - Built-in retries / backoff (handle in your own caller).
471
500
  - MCP sampling and `notifications/list_changed` (resource and prompt inventories are snapshotted at `Bridge` construction).
501
+ - MCP tasks API (`tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`) and `notifications/tasks/status` from the 2025-11-25 spec — the transport is in place; the methods land in a follow-up.
502
+ - Long-lived `GET` SSE channel for server-initiated notifications without an in-flight request — request-correlated SSE responses on `POST` are supported.
503
+ - MCP version negotiation downgrade: the `Protocol` object selected at construction is the version sent on every request; the server's response `protocolVersion` is recorded in `server_capabilities` but isn't auto-applied.
472
504
 
473
505
  ## Development
474
506
 
data/examples/README.md CHANGED
@@ -19,6 +19,7 @@ bundle exec examples/error_handling.rb
19
19
  bundle exec examples/mcp_agent.rb /path/to/sandbox # needs npx
20
20
  bundle exec examples/mcp_chat_with_tools.rb /path/to/sandbox # needs npx
21
21
  bundle exec examples/mcp_resources_and_prompts.rb /path/to/sandbox # needs npx
22
+ SF_TOKEN=... bundle exec examples/mcp_http_remote.rb # remote HTTP MCP server
22
23
  ```
23
24
 
24
25
  | Script | Endpoint | What it shows |
@@ -37,3 +38,4 @@ bundle exec examples/mcp_resources_and_prompts.rb /path/to/sandbox # needs npx
37
38
  | `mcp_agent.rb` | chat.completions + MCP | Minimal agent loop — bridge tools, dispatch tool_calls back |
38
39
  | `mcp_chat_with_tools.rb` | chat.completions + MCP | Heavily annotated walkthrough of the same loop, step by step |
39
40
  | `mcp_resources_and_prompts.rb` | chat.completions + MCP | Full coverage: tools + resources (synthetic read_resource) + prompts |
41
+ | `mcp_http_remote.rb` | MCP over HTTP Streamable transport | Connect to a remote MCP server (no child process), list its tools |
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env ruby
2
+ # Talk to a remote MCP server over HTTP Streamable transport (no stdio,
3
+ # no child process, no `npx`). The server is reached at a single HTTPS
4
+ # endpoint and may speak the 2025-11-25 MCP protocol.
5
+ #
6
+ # Usage:
7
+ # GROQ_API_KEY=gsk_... \
8
+ # SF_TOKEN=... \
9
+ # bundle exec examples/mcp_http_remote.rb
10
+ #
11
+ # By default this connects to the Spectrum Ferret staging MCP server.
12
+ # Override with MCP_URL=https://your-server/mcp.
13
+
14
+ require "bundler/setup"
15
+ require "groq_ruby"
16
+ require "json"
17
+
18
+ url = ENV.fetch("MCP_URL", "https://mcp-staging.spectrumferret.com/mcp")
19
+ token = ENV["SF_TOKEN"]
20
+
21
+ headers = {}
22
+ headers["Authorization"] = "Bearer #{token}" if token
23
+
24
+ config = GroqRuby::MCP::HttpServerConfig.new(
25
+ name: "remote",
26
+ url: url,
27
+ headers: headers
28
+ )
29
+
30
+ # By default Client.connect picks Protocol::V2025_11_25 for HTTP configs.
31
+ # Pass protocol: explicitly if you want to pin a specific version, e.g.
32
+ # GroqRuby::MCP::Client.connect(config, protocol: GroqRuby::MCP::Protocol::V2024_11_05.new)
33
+ mcp = GroqRuby::MCP::Client.connect(config)
34
+
35
+ begin
36
+ puts "Connected to: #{mcp.server_name} #{mcp.server_version}"
37
+ puts "Protocol: #{mcp.protocol.version}"
38
+ puts "Capabilities: #{mcp.server_capabilities.keys.join(", ")}"
39
+ puts
40
+
41
+ tools = mcp.tools_list
42
+ puts "Tools (#{tools.size}):"
43
+ tools.first(10).each { |t| puts " - #{t.name}: #{t.description}" }
44
+ puts " ..." if tools.size > 10
45
+ ensure
46
+ mcp.stop
47
+ end
@@ -18,15 +18,39 @@ module GroqRuby
18
18
  class Client
19
19
  DEFAULT_REQUEST_TIMEOUT = 30.0
20
20
 
21
- # Connect to a server via the default stdio transport and complete
22
- # the initialize handshake. Caller must call {#stop} when done.
21
+ # Connect to a server and complete the initialize handshake. The
22
+ # transport is chosen by config type {ServerConfig} stdio,
23
+ # {HttpServerConfig} → HTTP Streamable. Caller must call {#stop}
24
+ # when done.
23
25
  #
24
- # @param config [ServerConfig]
26
+ # @param config [ServerConfig, HttpServerConfig]
25
27
  # @param request_timeout [Numeric] seconds to wait for any single response
28
+ # @param protocol [Object, nil] protocol object selecting an MCP spec
29
+ # year. If `nil`, a sensible default is picked from the config type
30
+ # (stdio → 2024-11-05, HTTP → 2025-11-25).
26
31
  # @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)
32
+ def self.connect(config, request_timeout: DEFAULT_REQUEST_TIMEOUT, protocol: nil)
33
+ protocol ||= default_protocol_for(config)
34
+ transport = build_transport(config, protocol)
35
+ new(transport, request_timeout: request_timeout, protocol: protocol).tap(&:initialize_session)
36
+ end
37
+
38
+ # @api private
39
+ def self.build_transport(config, protocol)
40
+ case config
41
+ when HttpServerConfig
42
+ Transports::HttpStreamable.start(config, protocol_version: protocol.version)
43
+ else
44
+ Transports::Stdio.spawn(config)
45
+ end
46
+ end
47
+
48
+ # @api private
49
+ def self.default_protocol_for(config)
50
+ case config
51
+ when HttpServerConfig then Protocol::V2025_11_25.new
52
+ else Protocol::V2024_11_05.new
53
+ end
30
54
  end
31
55
 
32
56
  # @return [String, nil] the server-reported name, populated after handshake
@@ -35,12 +59,16 @@ module GroqRuby
35
59
  attr_reader :server_version
36
60
  # @return [Hash] the server's advertised capabilities
37
61
  attr_reader :server_capabilities
62
+ # @return [Object] the protocol object in use
63
+ attr_reader :protocol
38
64
 
39
65
  # @param transport [Transport]
40
66
  # @param request_timeout [Numeric]
41
- def initialize(transport, request_timeout: DEFAULT_REQUEST_TIMEOUT)
67
+ # @param protocol [Object] protocol object selecting an MCP spec year
68
+ def initialize(transport, request_timeout: DEFAULT_REQUEST_TIMEOUT, protocol: Protocol.default)
42
69
  @transport = transport
43
70
  @request_timeout = request_timeout
71
+ @protocol = protocol
44
72
  @next_id = 0
45
73
  @id_mutex = Mutex.new
46
74
  @pending = {}
@@ -52,11 +80,9 @@ module GroqRuby
52
80
  # {.connect}; safe to call again to re-handshake.
53
81
  # @return [Hash] the server's response payload
54
82
  def initialize_session
55
- result = request("initialize", {
56
- protocolVersion: PROTOCOL_VERSION,
57
- capabilities: {},
58
- clientInfo: {name: "groq_ruby", version: GroqRuby::VERSION}
59
- })
83
+ result = request("initialize", @protocol.initialize_params(
84
+ client_info: {name: "groq_ruby", version: GroqRuby::VERSION}
85
+ ))
60
86
  info = result["serverInfo"] || {}
61
87
  @server_name = info["name"]
62
88
  @server_version = info["version"]
@@ -0,0 +1,25 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Description of an MCP server reachable over HTTP Streamable transport.
4
+ # Sibling of {ServerConfig} (which describes a stdio-launched server).
5
+ # Immutable — build one per server, then pass to {Client.connect} or
6
+ # {Bridge.new}.
7
+ #
8
+ # @example A remote MCP server with bearer auth
9
+ # HttpServerConfig.new(
10
+ # name: "spectrumferret",
11
+ # url: "https://mcp-staging.spectrumferret.com/mcp",
12
+ # headers: {"Authorization" => "Bearer #{ENV['SF_TOKEN']}"}
13
+ # )
14
+ class HttpServerConfig < Data.define(:name, :url, :headers)
15
+ # @param name [String] short identifier used to namespace tools in {Bridge}
16
+ # @param url [String] full URL of the MCP endpoint (single endpoint —
17
+ # the transport POSTs JSON-RPC and may receive SSE responses)
18
+ # @param headers [Hash{String => String}] extra HTTP headers added to
19
+ # every request (e.g. authorization)
20
+ def initialize(name:, url:, headers: {})
21
+ super
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ module GroqRuby
2
+ module MCP
3
+ module Protocol
4
+ # MCP protocol version 2024-11-05 — the original public release.
5
+ # Stdio transport only; no tasks, no elicitation, no _meta fields.
6
+ #
7
+ # Class name underscores deliberately mirror the wire format.
8
+ class V2024_11_05 # rubocop:disable Naming/ClassAndModuleCamelCase
9
+ VERSION = "2024-11-05".freeze
10
+
11
+ # @return [String] the protocol version this object speaks
12
+ def version
13
+ VERSION
14
+ end
15
+
16
+ # Build the `params` payload for the JSON-RPC `initialize` request.
17
+ # @param client_info [Hash] `{name:, version:}` describing the client
18
+ # @return [Hash]
19
+ def initialize_params(client_info:)
20
+ {
21
+ protocolVersion: VERSION,
22
+ capabilities: {},
23
+ clientInfo: client_info
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ module GroqRuby
2
+ module MCP
3
+ module Protocol
4
+ # MCP protocol version 2025-11-25 — "The Task Master".
5
+ # HTTP/HTTPS transport only (no stdio). Adds tasks API, structured
6
+ # output, _meta fields, elicitation, OAuth 2.1 — the tasks methods
7
+ # land in a follow-up; this stub exposes only what the HTTP transport
8
+ # needs (the version string for the `MCP-Protocol-Version` header
9
+ # and the `protocolVersion` field in `initialize`).
10
+ #
11
+ # Class name underscores deliberately mirror the wire format.
12
+ class V2025_11_25 # rubocop:disable Naming/ClassAndModuleCamelCase
13
+ VERSION = "2025-11-25".freeze
14
+
15
+ # @return [String] the protocol version this object speaks
16
+ def version
17
+ VERSION
18
+ end
19
+
20
+ # Build the `params` payload for the JSON-RPC `initialize` request.
21
+ # @param client_info [Hash] `{name:, version:}` describing the client
22
+ # @return [Hash]
23
+ def initialize_params(client_info:)
24
+ {
25
+ protocolVersion: VERSION,
26
+ capabilities: {},
27
+ clientInfo: client_info
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,37 @@
1
+ module GroqRuby
2
+ module MCP
3
+ # Per-version MCP protocol objects. A {Client} delegates version-specific
4
+ # decisions (initialize payload shape, supported capabilities) to one of
5
+ # these so callers can target different MCP spec years against the same
6
+ # transport.
7
+ #
8
+ # client = GroqRuby::MCP::Client.connect(
9
+ # config,
10
+ # protocol: GroqRuby::MCP::Protocol::V2024_11_05.new
11
+ # )
12
+ #
13
+ # Without an explicit `protocol:`, {Client} uses {.default}.
14
+ module Protocol
15
+ # Look up a protocol class by its wire version string. Returns `nil`
16
+ # if the version is unknown to this client.
17
+ # @param version [String] e.g. `"2024-11-05"`
18
+ # @return [Class, nil]
19
+ def self.for(version)
20
+ registry[version]
21
+ end
22
+
23
+ # @return [Object] a fresh instance of the default protocol
24
+ def self.default
25
+ V2024_11_05.new
26
+ end
27
+
28
+ # @return [Hash{String => Class}]
29
+ def self.registry
30
+ {
31
+ V2024_11_05::VERSION => V2024_11_05,
32
+ V2025_11_25::VERSION => V2025_11_25
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,221 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module GroqRuby
6
+ module MCP
7
+ module Transports
8
+ # HTTP Streamable transport (the post-stdio transport defined by the
9
+ # MCP spec from 2025-03-26 onward and required by 2025-11-25).
10
+ #
11
+ # Single endpoint URL. Each outbound JSON-RPC frame is a `POST` whose
12
+ # response is either `application/json` (one frame) or
13
+ # `text/event-stream` (one or more frames followed by stream close).
14
+ # Notifications/responses get back a 202 with no body.
15
+ #
16
+ # `Mcp-Session-Id` is captured from the response to `initialize` and
17
+ # echoed on every subsequent request. `MCP-Protocol-Version` goes on
18
+ # every request.
19
+ #
20
+ # @example
21
+ # config = HttpServerConfig.new(name: "x", url: "https://example/mcp")
22
+ # transport = Transports::HttpStreamable.start(config, protocol_version: "2025-11-25")
23
+ # transport.on_message { |msg| ... }
24
+ # transport.send_message({jsonrpc: "2.0", id: 1, method: "tools/list"})
25
+ #
26
+ # PR 2 limitation: there is no long-lived `GET` SSE channel for
27
+ # server-initiated notifications. Server-initiated traffic that
28
+ # arrives interleaved on a `POST` SSE response is dispatched, but
29
+ # standalone notifications (e.g. `notifications/tools/list_changed`
30
+ # with no in-flight request) are not yet received. Adding the GET
31
+ # stream is part of the tasks-API follow-up.
32
+ class HttpStreamable
33
+ include Transport
34
+
35
+ DEFAULT_OPEN_TIMEOUT = 10
36
+ DEFAULT_READ_TIMEOUT = 60
37
+
38
+ # Construct and return a transport ready to {#send_message}. The
39
+ # underlying HTTP connection is not opened until the first send;
40
+ # this is a pure constructor by another name.
41
+ # @param config [HttpServerConfig]
42
+ # @param protocol_version [String] e.g. `"2025-11-25"`
43
+ # @return [HttpStreamable]
44
+ def self.start(config, protocol_version:)
45
+ new(config, protocol_version: protocol_version)
46
+ end
47
+
48
+ # @return [String] protocol version sent in the `MCP-Protocol-Version`
49
+ # header. Mutable so {Client} can update it after negotiation.
50
+ attr_accessor :protocol_version
51
+
52
+ # @return [String, nil] session id captured from the initialize
53
+ # response, or `nil` if the server is stateless
54
+ attr_reader :session_id
55
+
56
+ # @param config [HttpServerConfig]
57
+ # @param protocol_version [String]
58
+ # @param open_timeout [Numeric]
59
+ # @param read_timeout [Numeric]
60
+ def initialize(config, protocol_version:, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
61
+ @config = config
62
+ @protocol_version = protocol_version
63
+ @open_timeout = open_timeout
64
+ @read_timeout = read_timeout
65
+ @uri = URI.parse(config.url)
66
+ @on_message = nil
67
+ @session_id = nil
68
+ @session_mutex = Mutex.new
69
+ @threads = []
70
+ @threads_mutex = Mutex.new
71
+ @stopped = false
72
+ end
73
+
74
+ def send_message(message)
75
+ return if @stopped
76
+ thread = Thread.new { post(message) }
77
+ @threads_mutex.synchronize { @threads << thread }
78
+ nil
79
+ end
80
+
81
+ def on_message(&block)
82
+ @on_message = block
83
+ end
84
+
85
+ def stop
86
+ return if @stopped
87
+ @stopped = true
88
+ drain_threads
89
+ delete_session if @session_id
90
+ end
91
+
92
+ private
93
+
94
+ # Wait for in-flight POSTs to finish so we don't leave straggler
95
+ # threads making HTTP requests after the caller thinks we've shut
96
+ # down (especially important in tests, where stale threads pollute
97
+ # the next test's expectations).
98
+ def drain_threads
99
+ threads = @threads_mutex.synchronize { @threads.dup }
100
+ threads.each { |t| t.join(@read_timeout) }
101
+ end
102
+
103
+ def post(message)
104
+ req = build_request(Net::HTTP::Post, body: JSON.generate(message))
105
+ with_http do |http|
106
+ http.request(req) do |resp|
107
+ capture_session_id(resp)
108
+ handle_response(resp, message)
109
+ end
110
+ end
111
+ rescue => e
112
+ surface_request_failure(message, e)
113
+ end
114
+
115
+ def delete_session
116
+ req = build_request(Net::HTTP::Delete)
117
+ with_http { |http| http.request(req) }
118
+ rescue
119
+ # Session termination is best-effort; the server will GC stale
120
+ # sessions on its own.
121
+ end
122
+
123
+ def with_http(&block)
124
+ http = Net::HTTP.new(@uri.host, @uri.port)
125
+ http.use_ssl = (@uri.scheme == "https")
126
+ http.open_timeout = @open_timeout
127
+ http.read_timeout = @read_timeout
128
+ http.start(&block)
129
+ end
130
+
131
+ def build_request(klass, body: nil)
132
+ req = klass.new(@uri.request_uri)
133
+ apply_headers(req)
134
+ if body
135
+ req.body = body
136
+ req["Content-Type"] = "application/json"
137
+ end
138
+ req
139
+ end
140
+
141
+ def apply_headers(req)
142
+ req["Accept"] = "application/json, text/event-stream"
143
+ req["MCP-Protocol-Version"] = @protocol_version
144
+ if (sid = current_session_id)
145
+ req["Mcp-Session-Id"] = sid
146
+ end
147
+ @config.headers.each { |k, v| req[k] = v }
148
+ end
149
+
150
+ def current_session_id
151
+ @session_mutex.synchronize { @session_id }
152
+ end
153
+
154
+ def capture_session_id(resp)
155
+ value = resp["Mcp-Session-Id"] || resp["mcp-session-id"]
156
+ return if value.nil? || value.empty?
157
+ @session_mutex.synchronize { @session_id = value }
158
+ end
159
+
160
+ def handle_response(resp, request_message)
161
+ status = resp.code.to_i
162
+ case status
163
+ when 200 then dispatch_body(resp)
164
+ when 202 then nil
165
+ else surface_request_failure(request_message, TransportError.new("HTTP #{status} from MCP server #{@config.name.inspect}"))
166
+ end
167
+ end
168
+
169
+ def dispatch_body(resp)
170
+ ctype = resp["Content-Type"].to_s
171
+ if ctype.include?("text/event-stream")
172
+ dispatch_sse(resp)
173
+ else
174
+ dispatch_json_body(resp)
175
+ end
176
+ end
177
+
178
+ def dispatch_json_body(resp)
179
+ body = read_body(resp)
180
+ return if body.nil? || body.empty?
181
+ message = JSON.parse(body)
182
+ @on_message&.call(message)
183
+ rescue JSON::ParserError
184
+ # Malformed; nothing actionable. Drop.
185
+ end
186
+
187
+ def dispatch_sse(resp)
188
+ parser = Streaming::EventParser.new
189
+ buffer = +""
190
+ resp.read_body { |chunk| buffer << chunk }
191
+ parser.feed(buffer) do |_event, data, _id, _retry|
192
+ next if data.nil? || data.empty?
193
+ begin
194
+ @on_message&.call(JSON.parse(data))
195
+ rescue JSON::ParserError
196
+ # Skip malformed event payloads.
197
+ end
198
+ end
199
+ end
200
+
201
+ def read_body(resp)
202
+ # Net::HTTP block form requires read_body; outside the block the
203
+ # body would be auto-loaded.
204
+ chunks = +""
205
+ resp.read_body { |c| chunks << c }
206
+ chunks
207
+ end
208
+
209
+ def surface_request_failure(request_message, error)
210
+ id = request_message[:id] || request_message["id"]
211
+ return unless id && @on_message
212
+ @on_message.call({
213
+ "jsonrpc" => "2.0",
214
+ "id" => id,
215
+ "error" => {"code" => -32000, "message" => error.message}
216
+ })
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GroqRuby
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/groq_ruby.rb CHANGED
@@ -9,7 +9,9 @@ loader.inflector.inflect(
9
9
  "api_connection_error" => "APIConnectionError",
10
10
  "api_timeout_error" => "APITimeoutError",
11
11
  "api_status_error" => "APIStatusError",
12
- "api_response_error" => "APIResponseError"
12
+ "api_response_error" => "APIResponseError",
13
+ "v2024_11_05" => "V2024_11_05",
14
+ "v2025_11_25" => "V2025_11_25"
13
15
  )
14
16
  # Collapse organizational sub-directories so files inside them define
15
17
  # constants in the parent namespace. Public API stays flat — callers
data/sig/groq_ruby.rbs CHANGED
@@ -124,6 +124,18 @@ module GroqRuby
124
124
  ) -> void
125
125
  end
126
126
 
127
+ class HttpServerConfig
128
+ attr_reader name: String
129
+ attr_reader url: String
130
+ attr_reader headers: Hash[String, String]
131
+
132
+ def initialize: (
133
+ name: String,
134
+ url: String,
135
+ ?headers: Hash[String, String]
136
+ ) -> void
137
+ end
138
+
127
139
  module ClaudeDesktopConfig
128
140
  VAR_PATTERN: Regexp
129
141
 
@@ -131,6 +143,24 @@ module GroqRuby
131
143
  def self.parse: ((Hash[untyped, untyped] | String) input) -> Array[ServerConfig]
132
144
  end
133
145
 
146
+ module Protocol
147
+ def self.for: (String version) -> untyped
148
+ def self.default: () -> untyped
149
+ def self.registry: () -> Hash[String, untyped]
150
+
151
+ class V2024_11_05
152
+ VERSION: String
153
+ def version: () -> String
154
+ def initialize_params: (client_info: Hash[Symbol, String]) -> Hash[Symbol, untyped]
155
+ end
156
+
157
+ class V2025_11_25
158
+ VERSION: String
159
+ def version: () -> String
160
+ def initialize_params: (client_info: Hash[Symbol, String]) -> Hash[Symbol, untyped]
161
+ end
162
+ end
163
+
134
164
  class Tool
135
165
  attr_reader name: String
136
166
  attr_reader description: String?
@@ -159,9 +189,10 @@ module GroqRuby
159
189
  attr_reader server_name: String?
160
190
  attr_reader server_version: String?
161
191
  attr_reader server_capabilities: Hash[String, untyped]
192
+ attr_reader protocol: untyped
162
193
 
163
- def self.connect: (ServerConfig, ?request_timeout: Numeric) -> Client
164
- def initialize: (untyped transport, ?request_timeout: Numeric) -> void
194
+ def self.connect: ((ServerConfig | HttpServerConfig) config, ?request_timeout: Numeric, ?protocol: untyped) -> Client
195
+ def initialize: (untyped transport, ?request_timeout: Numeric, ?protocol: untyped) -> void
165
196
  def initialize_session: () -> Hash[String, untyped]
166
197
  def tools_list: () -> Array[Tool]
167
198
  def tools_call: (name: String, ?arguments: Hash[untyped, untyped]) -> Hash[String, untyped]
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: groq_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pawel Osiczko
@@ -118,6 +118,7 @@ files:
118
118
  - examples/file_upload.rb
119
119
  - examples/mcp_agent.rb
120
120
  - examples/mcp_chat_with_tools.rb
121
+ - examples/mcp_http_remote.rb
121
122
  - examples/mcp_resources_and_prompts.rb
122
123
  - examples/models_list.rb
123
124
  - examples/speech.rb
@@ -153,12 +154,17 @@ files:
153
154
  - lib/groq_ruby/mcp/errors/timeout_error.rb
154
155
  - lib/groq_ruby/mcp/errors/transport_error.rb
155
156
  - lib/groq_ruby/mcp/errors/unknown_tool_error.rb
157
+ - lib/groq_ruby/mcp/http_server_config.rb
156
158
  - lib/groq_ruby/mcp/json_rpc.rb
157
159
  - lib/groq_ruby/mcp/prompt.rb
160
+ - lib/groq_ruby/mcp/protocol.rb
161
+ - lib/groq_ruby/mcp/protocol/v2024_11_05.rb
162
+ - lib/groq_ruby/mcp/protocol/v2025_11_25.rb
158
163
  - lib/groq_ruby/mcp/resource.rb
159
164
  - lib/groq_ruby/mcp/server_config.rb
160
165
  - lib/groq_ruby/mcp/tool.rb
161
166
  - lib/groq_ruby/mcp/transport.rb
167
+ - lib/groq_ruby/mcp/transports/http_streamable.rb
162
168
  - lib/groq_ruby/mcp/transports/stdio.rb
163
169
  - lib/groq_ruby/models/audio/transcription.rb
164
170
  - lib/groq_ruby/models/audio/translation.rb
metadata.gz.sig CHANGED
Binary file