seekmodo-sdk 0.5.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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +52 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +32 -0
  6. data/Gemfile +11 -0
  7. data/README.md +135 -0
  8. data/docs/tool-catalog.README.md +12 -0
  9. data/lib/seekmodo/sdk/admin/client.rb +120 -0
  10. data/lib/seekmodo/sdk/auto_promoter.rb +124 -0
  11. data/lib/seekmodo/sdk/browser_token.rb +62 -0
  12. data/lib/seekmodo/sdk/circuit_breaker.rb +123 -0
  13. data/lib/seekmodo/sdk/connector/client.rb +250 -0
  14. data/lib/seekmodo/sdk/events/click_beacon.rb +58 -0
  15. data/lib/seekmodo/sdk/events/events_queue.rb +50 -0
  16. data/lib/seekmodo/sdk/exceptions/breaker_open_error.rb +10 -0
  17. data/lib/seekmodo/sdk/exceptions/client_error.rb +66 -0
  18. data/lib/seekmodo/sdk/exceptions/over_quota_error.rb +10 -0
  19. data/lib/seekmodo/sdk/exceptions/seekmodo_error.rb +8 -0
  20. data/lib/seekmodo/sdk/exceptions/signature_mismatch_error.rb +10 -0
  21. data/lib/seekmodo/sdk/exceptions/tenant_unavailable_error.rb +10 -0
  22. data/lib/seekmodo/sdk/hmac_signer.rb +43 -0
  23. data/lib/seekmodo/sdk/mcp/client.rb +105 -0
  24. data/lib/seekmodo/sdk/mode.rb +41 -0
  25. data/lib/seekmodo/sdk/mode_fsm.rb +52 -0
  26. data/lib/seekmodo/sdk/pairing.rb +114 -0
  27. data/lib/seekmodo/sdk/signature_mismatch_tracker.rb +52 -0
  28. data/lib/seekmodo/sdk/storage/memory/stores.rb +100 -0
  29. data/lib/seekmodo/sdk/storage/protocols.rb +47 -0
  30. data/lib/seekmodo/sdk/storefront/client.rb +71 -0
  31. data/lib/seekmodo/sdk/storefront/transport.rb +198 -0
  32. data/lib/seekmodo/sdk/tenant_snapshot.rb +65 -0
  33. data/lib/seekmodo/sdk/tools/registry.rb +88 -0
  34. data/lib/seekmodo/sdk/version.rb +7 -0
  35. data/lib/seekmodo/sdk.rb +33 -0
  36. data/lib/seekmodo-sdk.rb +3 -0
  37. metadata +109 -0
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+
6
+ module Seekmodo
7
+ module Sdk
8
+ module Storefront
9
+ class Transport
10
+ DEFAULT_BASE_URL = "https://gateway.seekmodo.com"
11
+ DEFAULT_TIMEOUT_MS = 8000
12
+
13
+ def initialize(
14
+ tenant_id:,
15
+ get_token:,
16
+ base_url: DEFAULT_BASE_URL,
17
+ connection: nil,
18
+ timeout_ms: DEFAULT_TIMEOUT_MS,
19
+ signal: nil,
20
+ on_error: nil,
21
+ get_region: nil,
22
+ user_agent: "seekmodo-ruby-sdk/0.5.0"
23
+ )
24
+ raise ArgumentError, "Seekmodo SDK: tenant_id is required" if tenant_id.to_s.empty?
25
+ raise ArgumentError, "Seekmodo SDK: get_token callback is required" unless get_token.respond_to?(:call)
26
+
27
+ @tenant_id = tenant_id
28
+ @get_token = get_token
29
+ @base_url = base_url.to_s.delete_suffix("/")
30
+ @timeout_ms = timeout_ms
31
+ @signal = signal
32
+ @on_error = on_error
33
+ @get_region = get_region
34
+ @user_agent = user_agent
35
+ @cached_token = nil
36
+ @owns_connection = connection.nil?
37
+ @connection = connection || Faraday.new do |f|
38
+ f.options.timeout = timeout_ms / 1000.0
39
+ f.adapter Faraday.default_adapter
40
+ end
41
+ end
42
+
43
+ def clear_token_cache
44
+ @cached_token = nil
45
+ end
46
+
47
+ def call(tool, args = {}, opts = {})
48
+ begin
49
+ call_once(tool, args, opts, false)
50
+ rescue AuthError => e
51
+ clear_token_cache
52
+ begin
53
+ call_once(tool, args, opts, true)
54
+ rescue StandardError => retry_err
55
+ @on_error&.call(retry_err, tool: tool)
56
+ raise retry_err
57
+ end
58
+ rescue StandardError => e
59
+ @on_error&.call(e, tool: tool)
60
+ raise e
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def call_once(tool, args, opts, is_retry)
67
+ token = resolve_token(is_retry)
68
+ url = "#{@base_url}/v1/#{Faraday::Utils.escape(tool)}"
69
+ headers = {
70
+ "Content-Type" => "application/json",
71
+ "Authorization" => "Bearer #{token}",
72
+ "X-Seekmodo-Tenant" => @tenant_id,
73
+ "X-Seekmodo-SDK" => @user_agent,
74
+ "User-Agent" => @user_agent
75
+ }
76
+
77
+ if @get_region
78
+ begin
79
+ slug = @get_region.call
80
+ headers["Seekmodo-Region"] = slug if slug.is_a?(String) && !slug.empty?
81
+ rescue StandardError
82
+ # ignore misconfigured region hook
83
+ end
84
+ end
85
+
86
+ timeout_ms = opts[:timeout_ms] || @timeout_ms
87
+ response = @connection.post(url) do |req|
88
+ req.headers.update(headers)
89
+ req.body = JSON.generate(args)
90
+ req.options.timeout = timeout_ms / 1000.0
91
+ end
92
+
93
+ body = response.body.to_s.empty? ? nil : safe_json(response.body)
94
+
95
+ if [401, 403].include?(response.status)
96
+ raise AuthError.new(response.status, body, tool)
97
+ end
98
+ if response.status == 402
99
+ raise QuotaError.new(body, tool)
100
+ end
101
+ if response.status >= 500
102
+ raise ServerError.new(response.status, body, tool)
103
+ end
104
+ unless response.status >= 200 && response.status < 300
105
+ raise RequestError.new(response.status, body, tool)
106
+ end
107
+
108
+ body
109
+ rescue Faraday::Error => e
110
+ raise NetworkError.new(e, tool)
111
+ end
112
+
113
+ def resolve_token(force_refresh)
114
+ now_ms = (Time.now.to_f * 1000).to_i
115
+ if !force_refresh && @cached_token && @cached_token[:expires_at] - 10_000 > now_ms
116
+ return @cached_token[:token]
117
+ end
118
+
119
+ result = @get_token.call
120
+ if result.is_a?(String)
121
+ @cached_token = { token: result, expires_at: now_ms + 60_000 }
122
+ return result
123
+ end
124
+
125
+ if result.is_a?(Hash) && result["token"].is_a?(String) && result["expires_at"].is_a?(Numeric)
126
+ @cached_token = { token: result["token"], expires_at: result["expires_at"].to_i }
127
+ return result["token"]
128
+ end
129
+
130
+ raise ArgumentError, "Seekmodo SDK: get_token must return a string or { token, expires_at }"
131
+ end
132
+
133
+ def safe_json(text)
134
+ JSON.parse(text)
135
+ rescue JSON::ParserError
136
+ text
137
+ end
138
+
139
+ class Error < StandardError
140
+ attr_reader :tool
141
+
142
+ def initialize(tool)
143
+ @tool = tool
144
+ super()
145
+ end
146
+ end
147
+
148
+ class AuthError < Error
149
+ attr_reader :status, :body
150
+
151
+ def initialize(status, body, tool)
152
+ @status = status
153
+ @body = body
154
+ super(tool)
155
+ end
156
+ end
157
+
158
+ class QuotaError < Error
159
+ attr_reader :body
160
+
161
+ def initialize(body, tool)
162
+ @body = body
163
+ super(tool)
164
+ end
165
+ end
166
+
167
+ class ServerError < Error
168
+ attr_reader :status, :body
169
+
170
+ def initialize(status, body, tool)
171
+ @status = status
172
+ @body = body
173
+ super(tool)
174
+ end
175
+ end
176
+
177
+ class RequestError < Error
178
+ attr_reader :status, :body
179
+
180
+ def initialize(status, body, tool)
181
+ @status = status
182
+ @body = body
183
+ super(tool)
184
+ end
185
+ end
186
+
187
+ class NetworkError < Error
188
+ attr_reader :cause
189
+
190
+ def initialize(cause, tool)
191
+ @cause = cause
192
+ super(tool)
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connector/client"
4
+ require_relative "storage/protocols"
5
+
6
+ module Seekmodo
7
+ module Sdk
8
+ class TenantSnapshot
9
+ DEFAULT_TTL_SECONDS = 300
10
+ DEFAULT_STALE_TTL_SECONDS = 60
11
+ CACHE_KEY = "numinix.seekmodo.tenant_snapshot"
12
+ CACHE_KEY_FETCHED_AT = "numinix.seekmodo.tenant_snapshot.fetched_at"
13
+
14
+ def initialize(
15
+ client,
16
+ cache,
17
+ ttl_seconds: DEFAULT_TTL_SECONDS,
18
+ stale_after_seconds: DEFAULT_STALE_TTL_SECONDS,
19
+ clock: nil
20
+ )
21
+ @client = client
22
+ @cache = cache
23
+ @ttl_seconds = ttl_seconds
24
+ @stale_after_seconds = [stale_after_seconds, ttl_seconds].min
25
+ @clock = clock || -> { Time.now.to_i }
26
+ end
27
+
28
+ def get
29
+ cached = @cache.get(CACHE_KEY)
30
+ fetched_at = @cache.get(CACHE_KEY_FETCHED_AT, 0).to_i
31
+
32
+ if cached.is_a?(Hash) && fetched_at > 0
33
+ age = @clock.call - fetched_at
34
+ if age < @stale_after_seconds
35
+ return cached
36
+ end
37
+
38
+ begin
39
+ return refresh
40
+ rescue StandardError
41
+ return cached
42
+ end
43
+ end
44
+
45
+ begin
46
+ refresh
47
+ rescue StandardError
48
+ {}
49
+ end
50
+ end
51
+
52
+ def refresh
53
+ snapshot = @client.tenant_snapshot
54
+ @cache.set(CACHE_KEY, snapshot, @ttl_seconds)
55
+ @cache.set(CACHE_KEY_FETCHED_AT, @clock.call, @ttl_seconds)
56
+ snapshot
57
+ end
58
+
59
+ def forget
60
+ @cache.delete(CACHE_KEY)
61
+ @cache.delete(CACHE_KEY_FETCHED_AT)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seekmodo
4
+ module Sdk
5
+ module Tools
6
+ class Registry
7
+ ADMIN_PREFIXES = %w[
8
+ synonyms. pins. ltr. analytics. tenant. merchandising. experiments.
9
+ catalog. queries. segments. banners. deboosts. esp. regions.
10
+ recommendations. bundles. image_search. bot_check. schema.
11
+ ].freeze
12
+
13
+ ADMIN_TOOLS = %w[
14
+ synonyms.list synonyms.add synonyms.remove
15
+ pins.list pins.set pins.remove
16
+ ltr.status ltr.retrain ltr.toggle ltr.config.set
17
+ analytics.top_queries analytics.zero_results
18
+ tenant.snapshot tenant.config tenant.config.set
19
+ ].freeze
20
+
21
+ def initialize(connector: nil, admin: nil, tenant_id: nil)
22
+ @connector = connector
23
+ @admin = admin
24
+ @tenant_id = tenant_id
25
+ end
26
+
27
+ def call(tool, args = {}, tenant_id: @tenant_id)
28
+ normalized = normalize_tool(tool)
29
+ if admin_tool?(normalized)
30
+ unless @admin
31
+ raise ArgumentError, "Admin client required for tool #{normalized}"
32
+ end
33
+ unless tenant_id
34
+ raise ArgumentError, "tenant_id required for admin tool #{normalized}"
35
+ end
36
+ return @admin.call(normalized, args, tenant_id: tenant_id)
37
+ end
38
+
39
+ unless @connector
40
+ raise ArgumentError, "Connector client required for tool #{normalized}"
41
+ end
42
+
43
+ route_connector(normalized, args)
44
+ end
45
+
46
+ private
47
+
48
+ def normalize_tool(tool)
49
+ tool.to_s.tr("_", ".")
50
+ end
51
+
52
+ def admin_tool?(tool)
53
+ return true if ADMIN_TOOLS.include?(tool)
54
+ return true if tool.start_with?("admin.")
55
+
56
+ ADMIN_PREFIXES.any? { |prefix| tool.start_with?(prefix) }
57
+ end
58
+
59
+ def route_connector(tool, args)
60
+ case tool
61
+ when "search"
62
+ @connector.search(args)
63
+ when "index"
64
+ documents = args["documents"] || args[:documents] || []
65
+ action = args["action"] || args[:action] || "upsert"
66
+ @connector.index(documents, action: action)
67
+ when "events"
68
+ events = args["events"] || args[:events] || []
69
+ @connector.events(events)
70
+ when "tenant.handshake"
71
+ @connector.tenant_handshake
72
+ when "tenant.snapshot"
73
+ @connector.tenant_snapshot
74
+ when "tenants/token"
75
+ audience = args["audience"] || args[:audience]
76
+ @connector.browser_token(audience)
77
+ when "tools"
78
+ @connector.tools
79
+ when "health"
80
+ @connector.health
81
+ else
82
+ @connector.post_json("/v1/#{tool}", args)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seekmodo
4
+ module Sdk
5
+ VERSION = "0.5.0"
6
+ end
7
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sdk/version"
4
+ require_relative "sdk/exceptions/seekmodo_error"
5
+ require_relative "sdk/exceptions/client_error"
6
+ require_relative "sdk/exceptions/tenant_unavailable_error"
7
+ require_relative "sdk/exceptions/over_quota_error"
8
+ require_relative "sdk/exceptions/signature_mismatch_error"
9
+ require_relative "sdk/exceptions/breaker_open_error"
10
+ require_relative "sdk/hmac_signer"
11
+ require_relative "sdk/mode"
12
+ require_relative "sdk/circuit_breaker"
13
+ require_relative "sdk/connector/client"
14
+ require_relative "sdk/tenant_snapshot"
15
+ require_relative "sdk/mode_fsm"
16
+ require_relative "sdk/auto_promoter"
17
+ require_relative "sdk/pairing"
18
+ require_relative "sdk/browser_token"
19
+ require_relative "sdk/signature_mismatch_tracker"
20
+ require_relative "sdk/storage/protocols"
21
+ require_relative "sdk/storage/memory/stores"
22
+ require_relative "sdk/events/click_beacon"
23
+ require_relative "sdk/events/events_queue"
24
+ require_relative "sdk/storefront/transport"
25
+ require_relative "sdk/storefront/client"
26
+ require_relative "sdk/admin/client"
27
+ require_relative "sdk/mcp/client"
28
+ require_relative "sdk/tools/registry"
29
+
30
+ module Seekmodo
31
+ module Sdk
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "seekmodo/sdk"
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: seekmodo-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Seekmodo
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.7'
41
+ description: Ruby SDK for the Seekmodo MCP gateway with HMAC connector transport,
42
+ JWT storefront client, admin tools, and JSON-RPC MCP support.
43
+ email:
44
+ - support@seekmodo.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".github/workflows/ci.yml"
50
+ - ".gitignore"
51
+ - ".rspec"
52
+ - ".rubocop.yml"
53
+ - Gemfile
54
+ - README.md
55
+ - docs/tool-catalog.README.md
56
+ - lib/seekmodo-sdk.rb
57
+ - lib/seekmodo/sdk.rb
58
+ - lib/seekmodo/sdk/admin/client.rb
59
+ - lib/seekmodo/sdk/auto_promoter.rb
60
+ - lib/seekmodo/sdk/browser_token.rb
61
+ - lib/seekmodo/sdk/circuit_breaker.rb
62
+ - lib/seekmodo/sdk/connector/client.rb
63
+ - lib/seekmodo/sdk/events/click_beacon.rb
64
+ - lib/seekmodo/sdk/events/events_queue.rb
65
+ - lib/seekmodo/sdk/exceptions/breaker_open_error.rb
66
+ - lib/seekmodo/sdk/exceptions/client_error.rb
67
+ - lib/seekmodo/sdk/exceptions/over_quota_error.rb
68
+ - lib/seekmodo/sdk/exceptions/seekmodo_error.rb
69
+ - lib/seekmodo/sdk/exceptions/signature_mismatch_error.rb
70
+ - lib/seekmodo/sdk/exceptions/tenant_unavailable_error.rb
71
+ - lib/seekmodo/sdk/hmac_signer.rb
72
+ - lib/seekmodo/sdk/mcp/client.rb
73
+ - lib/seekmodo/sdk/mode.rb
74
+ - lib/seekmodo/sdk/mode_fsm.rb
75
+ - lib/seekmodo/sdk/pairing.rb
76
+ - lib/seekmodo/sdk/signature_mismatch_tracker.rb
77
+ - lib/seekmodo/sdk/storage/memory/stores.rb
78
+ - lib/seekmodo/sdk/storage/protocols.rb
79
+ - lib/seekmodo/sdk/storefront/client.rb
80
+ - lib/seekmodo/sdk/storefront/transport.rb
81
+ - lib/seekmodo/sdk/tenant_snapshot.rb
82
+ - lib/seekmodo/sdk/tools/registry.rb
83
+ - lib/seekmodo/sdk/version.rb
84
+ homepage: https://seekmodo.com/docs/sdk
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://seekmodo.com/docs/sdk
89
+ source_code_uri: https://github.com/numinix/seekmodo-ruby-sdk
90
+ post_install_message:
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 3.1.0
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.5.22
106
+ signing_key:
107
+ specification_version: 4
108
+ summary: Seekmodo Ruby SDK — connector, storefront, admin, and MCP clients
109
+ test_files: []