mistri 0.4.1 → 0.6.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. metadata +38 -3
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # How a background child actually executes. The spawn tool hands every
5
+ # dispatcher two things: a versioned serializable spec (name, session_id,
6
+ # parent_session_id, type, instructions, tool_names, model, task) and a
7
+ # runner closure that invokes the host runtime factory inside this process.
8
+ #
9
+ # In-process dispatchers just call the runner. A queue dispatcher ignores
10
+ # the runner, enqueues the spec, and its job reconstructs the pieces from
11
+ # host registries and calls SubAgent.run_dispatched:
12
+ #
13
+ # dispatcher: ->(spec, _runner) { ChildRunJob.perform_async(spec) }
14
+ #
15
+ # The runner invokes the host factory inside the worker and keeps the
16
+ # spawn-time event sink. In-process children stream to whoever watched the
17
+ # spawn; that sink must outlive the parent's turn (a broadcast lambda does,
18
+ # a request-scoped object may not) and hears from the worker's thread,
19
+ # concurrently with the parent's own events. The gem's sinks tolerate that;
20
+ # a custom one must too.
21
+ module Dispatchers
22
+ # Runs the child synchronously inside the spawn call and still answers
23
+ # in receipt form: background degrades gracefully where no concurrency
24
+ # exists (consoles, tests), and the receipt stays truthful because it
25
+ # reads the child's status after dispatch.
26
+ class Inline
27
+ def call(_spec, runner) = runner.call
28
+ end
29
+
30
+ # Real in-process background: the child runs on its own thread and the
31
+ # spawn call returns immediately. Enough for development and for hosts
32
+ # whose children are short; queue-backed hosts plug their own lambda.
33
+ class Thread
34
+ def call(spec, runner)
35
+ ::Thread.new do
36
+ runner.call
37
+ rescue StandardError => e
38
+ reported = EventDelivery.original(e)
39
+ # Execution failures have a durable terminal before re-raising;
40
+ # cleanup may raise after a successful terminal. Neither should
41
+ # make a thread die loudly or disappear without a diagnostic.
42
+ warn "mistri: background runner for #{spec["name"].inspect} crashed: " \
43
+ "#{reported.class}: #{reported.message}"
44
+ end
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/mistri/errors.rb CHANGED
@@ -11,6 +11,10 @@ module Mistri
11
11
  # Missing or contradictory setup: an unknown model, an absent API key.
12
12
  class ConfigurationError < Error; end
13
13
 
14
+ # A queue payload is not the grant persisted for its child. The unchanged
15
+ # delivery is poison: discard and alert instead of retrying it.
16
+ class DispatchGrantError < ConfigurationError; end
17
+
14
18
  # A provider request failed. Carries the HTTP status and response body when
15
19
  # the transport got that far.
16
20
  class ProviderError < Error
@@ -57,20 +61,89 @@ module Mistri
57
61
  def self.default_message = "provider server error"
58
62
  end
59
63
 
64
+ # An inbound body or protocol record crossed its configured byte boundary.
65
+ class ResponseTooLargeError < Error
66
+ attr_reader :kind, :limit
67
+
68
+ def initialize(kind:, limit:)
69
+ @kind = kind
70
+ @limit = limit
71
+ label = kind.to_s.tr("_", " ")
72
+ super("#{label} exceeded the byte limit (#{limit} bytes)")
73
+ end
74
+ end
75
+
76
+ # A JSON protocol record crossed a structural boundary before parsing.
77
+ class ResponseTooComplexError < Error
78
+ attr_reader :kind, :limit
79
+
80
+ def initialize(kind:, limit:)
81
+ @kind = kind
82
+ @limit = limit
83
+ label = kind.to_s.tr("_", " ")
84
+ super("#{label} exceeded the complexity limit (#{limit})")
85
+ end
86
+ end
87
+
88
+ # A non-replayable request has no confirmed response, so execution is unknown.
89
+ class AmbiguousDeliveryError < ProviderError
90
+ def self.default_message
91
+ "connection failed before the response was confirmed; the operation may have completed; " \
92
+ "do not retry automatically; verify external state first"
93
+ end
94
+ end
95
+
96
+ # The provider rejected the request itself: an invalid prompt, a bad
97
+ # image, a policy violation. Never retried; the same input cannot succeed.
98
+ class InvalidRequestError < ProviderError
99
+ def self.default_message = "provider rejected the request"
100
+ end
101
+
60
102
  # Tool arguments or structured output that violate their declared schema.
61
103
  class SchemaError < Error; end
62
104
 
63
105
  # A run cancelled by the host, raised only when the caller opts into raising.
64
106
  class AbortError < Error; end
65
107
 
66
- # A run stopped by its turn, token, cost, or wall-clock budget.
67
- class BudgetError < Error; end
108
+ # A cost-budgeted request returned without trustworthy accounting. Usage and
109
+ # provider_message preserve the uncertain attempt for host reconciliation.
110
+ class BudgetError < Error
111
+ attr_reader :usage, :provider_message
112
+
113
+ def initialize(message = nil, usage: nil, provider_message: nil)
114
+ @usage = usage
115
+ @provider_message = provider_message
116
+ super(message)
117
+ end
118
+ end
68
119
 
69
120
  # A text edit that did not match uniquely, or overlapped another edit.
70
121
  class EditError < Error; end
71
122
 
72
- # Compaction could not produce a usable summary.
73
- class CompactionError < Error; end
123
+ # A conditional document write lost to a different committed version. The
124
+ # revisions stay off the message so a stale full replacement cannot borrow
125
+ # a fresh token without reading the matching content.
126
+ class WorkspaceConflictError < Error
127
+ attr_reader :path, :expected_revision, :actual_revision
128
+
129
+ def initialize(path, expected_revision: nil, actual_revision: nil)
130
+ @path = path.to_s.dup.freeze
131
+ @expected_revision = expected_revision.nil? ? nil : expected_revision.to_s.dup.freeze
132
+ @actual_revision = actual_revision.nil? ? nil : actual_revision.to_s.dup.freeze
133
+ super("document #{@path.inspect} changed; read it again before retrying")
134
+ end
135
+ end
136
+
137
+ # Compaction could not produce a usable summary. Usage preserves any billed
138
+ # provider attempt even though no checkpoint was written.
139
+ class CompactionError < Error
140
+ attr_reader :usage
141
+
142
+ def initialize(message = nil, usage: nil)
143
+ @usage = usage
144
+ super(message)
145
+ end
146
+ end
74
147
 
75
148
  # The machine-readable shape of a stream failure, carried on errored
76
149
  # assistant messages so retry policies and hosts can classify without
@@ -84,6 +157,12 @@ module Mistri
84
157
  when RateLimitError
85
158
  { "type" => "RateLimitError", "status" => reason.status,
86
159
  "retry_after" => reason.retry_after }.compact
160
+ when ResponseTooLargeError
161
+ { "type" => "ResponseTooLargeError", "kind" => reason.kind.to_s,
162
+ "limit" => reason.limit }
163
+ when ResponseTooComplexError
164
+ { "type" => "ResponseTooComplexError", "kind" => reason.kind.to_s,
165
+ "limit" => reason.limit }
87
166
  when ProviderError
88
167
  { "type" => reason.class.name.split("::").last, "status" => reason.status }.compact
89
168
  when Exception then { "type" => reason.class.name }
data/lib/mistri/event.rb CHANGED
@@ -11,34 +11,54 @@ module Mistri
11
11
  # message's content list.
12
12
  # origin names the sub-agent an event came from: nil for this agent's own
13
13
  # turns, and nesting joins names left to right ("researcher>writer").
14
- # duration is the tool's execution time in seconds on :tool_result
15
- # events; nil where nothing ran (denials, interruptions).
14
+ # duration is the measured tool execution time on :tool_result events; nil
15
+ # means no duration is available, not necessarily that no side effect ran.
16
+ # tool_error is explicit on :tool_result and does not change Event#error?,
17
+ # which means a terminal provider-turn failure.
16
18
  class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
17
19
  :reason, :message, :error_message, :partial, :origin,
18
- :duration, :attempt, :max_attempts, :delay)
20
+ :duration, :attempt, :max_attempts, :delay,
21
+ :agent, :session_id, :status, :tool_error)
19
22
  # The stream types come from a provider mid-turn; the loop adds
20
- # :tool_result after it runs each tool, :approval_needed when a gated
21
- # call parks for a human, :compacting/:compaction around a context
23
+ # :tool_started when a resolved tool commits to execution, :tool_result
24
+ # after it finishes, :approval_needed when a gated call parks for a human,
25
+ # :compacting/:compaction around a context
22
26
  # compaction, and :retry (with attempt, max_attempts, delay) before it
23
27
  # waits out a transient failure, so one subscription sees the whole
24
28
  # exchange. :done and :error are loop-owned and terminal: only the
25
29
  # accepted attempt's terminal event reaches the subscriber.
30
+ # :subagent_report announces a background child's terminal outcome
31
+ # (agent, session_id, status; content carries the report), so a UI that
32
+ # watched the spawn can settle the child's lane the moment it ends.
26
33
  TYPES = %i[
27
34
  start
28
35
  text_start text_delta text_end
29
36
  thinking_start thinking_delta thinking_end
30
37
  toolcall_start toolcall_delta toolcall_end
31
38
  done error
32
- tool_result approval_needed
39
+ tool_started tool_result approval_needed
33
40
  compacting compaction
34
41
  retry
42
+ subagent_report
35
43
  ].freeze
36
44
 
37
45
  def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
38
46
  reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
39
- duration: nil, attempt: nil, max_attempts: nil, delay: nil)
47
+ duration: nil, attempt: nil, max_attempts: nil, delay: nil,
48
+ agent: nil, session_id: nil, status: nil, tool_error: nil)
40
49
  raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
41
50
 
51
+ if type == :tool_result
52
+ unless [true, false].include?(tool_error)
53
+ raise ArgumentError, "tool_result events require a boolean tool_error"
54
+ end
55
+ elsif !tool_error.nil?
56
+ raise ArgumentError, "tool_error is only valid on tool_result events"
57
+ end
58
+ if type == :tool_result && message && message.tool_error != tool_error
59
+ raise ArgumentError, "event tool_error must match its message"
60
+ end
61
+
42
62
  super
43
63
  end
44
64
 
@@ -46,13 +66,15 @@ module Mistri
46
66
 
47
67
  def error? = type == :error
48
68
 
69
+ def tool_error? = tool_error == true
70
+
49
71
  def terminal? = done? || error?
50
72
 
51
73
  # Partials are ephemeral streaming state and stay out of serialization.
52
74
  def to_h
53
75
  { type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
54
76
  reason:, message: message&.to_h, error_message:, origin:, duration:,
55
- attempt:, max_attempts:, delay: }.compact
77
+ attempt:, max_attempts:, delay:, agent:, session_id:, status:, tool_error: }.compact
56
78
  end
57
79
  end
58
80
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Preserves subscriber exception identity across internal rescue boundaries.
5
+ module EventDelivery
6
+ # Tags a subscriber failure with the boundary that first observed it.
7
+ class Failure < StandardError
8
+ attr_reader :original, :boundary
9
+
10
+ def initialize(original, boundary)
11
+ @original = original
12
+ @boundary = boundary
13
+ super(original.message)
14
+ set_backtrace(original.backtrace)
15
+ end
16
+ end
17
+
18
+ # Wraps one subscriber and unwraps only failures that it created.
19
+ class Boundary
20
+ def initialize(subscriber, passthrough: [])
21
+ @subscriber = subscriber
22
+ @passthrough = passthrough.freeze
23
+ @callable = method(:call).to_proc
24
+ end
25
+
26
+ def to_proc = @callable
27
+
28
+ def call(event)
29
+ @subscriber.call(event)
30
+ rescue Failure
31
+ raise
32
+ rescue StandardError => e
33
+ raise if @passthrough.any? { |error_class| e.is_a?(error_class) }
34
+
35
+ raise Failure.new(e, self)
36
+ end
37
+
38
+ def unwrap(error)
39
+ error.boundary.equal?(self) ? error.original : error
40
+ end
41
+ end
42
+
43
+ module_function
44
+
45
+ def wrap(subscriber, passthrough: [])
46
+ Boundary.new(subscriber, passthrough: passthrough) if subscriber
47
+ end
48
+
49
+ def unwrap(error, boundary)
50
+ return error unless error.is_a?(Failure) && boundary
51
+
52
+ boundary.unwrap(error)
53
+ end
54
+
55
+ def original(error)
56
+ error.is_a?(Failure) ? error.original : error
57
+ end
58
+ end
59
+ private_constant :EventDelivery
60
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Opt-in, exactly like Stores::ActiveRecord: the gem never requires Rails.
4
+ # require "mistri/locks/rails_cache".
5
+ module Mistri
6
+ module Locks
7
+ # Leases and flags over an ActiveSupport::Cache::Store (Redis-backed in
8
+ # any real deployment), so every process in the fleet reads the same
9
+ # truth. Pass any cache store duck; defaults to Rails.cache.
10
+ class RailsCache
11
+ def initialize(cache: nil, namespace: "mistri:locks")
12
+ @cache = cache || (defined?(Rails) && Rails.cache) ||
13
+ raise(ConfigurationError, "no cache store; pass cache: or configure Rails.cache")
14
+ @namespace = namespace
15
+ end
16
+
17
+ def acquire(key, ttl:)
18
+ @cache.write(scoped(key), true, unless_exist: true, expires_in: ttl)
19
+ end
20
+
21
+ def renew(key, ttl:)
22
+ @cache.write(scoped(key), true, expires_in: ttl)
23
+ nil
24
+ end
25
+
26
+ def release(key)
27
+ @cache.delete(scoped(key))
28
+ nil
29
+ end
30
+
31
+ def held?(key)
32
+ @cache.exist?(scoped(key))
33
+ end
34
+
35
+ def set_flag(key, ttl: 300)
36
+ renew(key, ttl: ttl)
37
+ end
38
+
39
+ def flag?(key) = held?(key)
40
+
41
+ def clear_flag(key) = release(key)
42
+
43
+ private
44
+
45
+ def scoped(key) = "#{@namespace}:#{key}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Mistri
6
+ # Cross-process coordination for hosts that run loops in more than one
7
+ # process: leases that say "this is alive right now" and flags that carry
8
+ # one-bit requests (stop) between processes. Everything expires on its
9
+ # own, so a crashed holder never wedges anything; a healthy holder renews
10
+ # on a heartbeat, while a stalled process can still outlive its lease.
11
+ #
12
+ # Configure once at boot:
13
+ #
14
+ # Mistri.locks = Mistri::Locks::Memory.new # single process
15
+ # Mistri.locks = Mistri::Locks::RailsCache.new # requires opt-in file
16
+ #
17
+ # An adapter implements seven methods: acquire(key, ttl:) -> bool,
18
+ # renew(key, ttl:), release(key), held?(key), set_flag(key, ttl:),
19
+ # flag?(key), clear_flag(key). With no adapter configured, everything
20
+ # lease-aware degrades gracefully: children read :running instead of
21
+ # :interrupted, and holds are no-ops.
22
+ module Locks
23
+ LEASE_TTL = 180
24
+ HEARTBEAT = 60
25
+
26
+ module_function
27
+
28
+ # Hold a lease for the duration of a block of work: acquire, renew on a
29
+ # heartbeat from a background thread, release on the way out. Returns a
30
+ # Hold to release in an ensure, or nil when no adapter is configured or
31
+ # the lease is already held elsewhere: a caller that was refused must
32
+ # never renew or delete the real holder's key. Callers that need to
33
+ # tell refusal apart from no-adapter use the adapter's acquire directly.
34
+ #
35
+ # With stop_key: and signal:, the same thread watches the flag and trips
36
+ # the signal within a tick, so a stop request written in another process
37
+ # becomes this run's cooperative abort.
38
+ def hold(key, ttl: LEASE_TTL, heartbeat: HEARTBEAT, stop_key: nil, signal: nil)
39
+ adapter = Mistri.locks
40
+ return nil unless adapter
41
+ return nil unless adapter.acquire(key, ttl: ttl)
42
+
43
+ Hold.new(adapter, key, ttl, heartbeat, stop_key: stop_key, signal: signal)
44
+ end
45
+
46
+ # A held lease: one thread renews it on the heartbeat and watches the
47
+ # stop flag between renewals. Renewal runs on a monotonic deadline (the
48
+ # last sleep before it is the remaining fraction, never a full tick, so
49
+ # the cadence is exact and a lease can never lapse waiting for a tick to
50
+ # round up). release stops the thread (and joins it, so a mid-renewal
51
+ # tick can never re-stamp a lease that was just released) and deletes
52
+ # the key.
53
+ class Hold
54
+ def initialize(adapter, key, ttl, heartbeat, stop_key: nil, signal: nil)
55
+ @adapter = adapter
56
+ @key = key
57
+ @stopping = false
58
+ @thread = Thread.new do
59
+ deadline = now + heartbeat
60
+ until @stopping
61
+ sleep (deadline - now).clamp(0.01, 1.0)
62
+ break if @stopping
63
+
64
+ signal.abort!("stopped by user") if stop_key && signal && adapter.flag?(stop_key)
65
+ if now >= deadline
66
+ adapter.renew(key, ttl: ttl)
67
+ deadline = now + heartbeat
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def release
74
+ @stopping = true
75
+ @thread.kill
76
+ @thread.join(2)
77
+ @adapter.release(@key)
78
+ end
79
+
80
+ private
81
+
82
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+ end
84
+
85
+ # The in-process adapter: real TTL semantics over a hash, for tests,
86
+ # development, and the thread dispatcher. Monitor-synchronized; expiry
87
+ # is judged at read time, so nothing needs a sweeper.
88
+ class Memory
89
+ def initialize
90
+ @entries = {}
91
+ @lock = Monitor.new
92
+ end
93
+
94
+ def acquire(key, ttl:)
95
+ @lock.synchronize do
96
+ return false if live?(key)
97
+
98
+ @entries[key] = deadline(ttl)
99
+ true
100
+ end
101
+ end
102
+
103
+ def renew(key, ttl:)
104
+ @lock.synchronize { @entries[key] = deadline(ttl) }
105
+ nil
106
+ end
107
+
108
+ def release(key)
109
+ @lock.synchronize { @entries.delete(key) }
110
+ nil
111
+ end
112
+
113
+ def held?(key)
114
+ @lock.synchronize { live?(key) }
115
+ end
116
+
117
+ def set_flag(key, ttl: 300)
118
+ renew(key, ttl: ttl)
119
+ end
120
+
121
+ def flag?(key) = held?(key)
122
+
123
+ def clear_flag(key) = release(key)
124
+
125
+ private
126
+
127
+ def live?(key)
128
+ deadline = @entries[key]
129
+ return false unless deadline
130
+ return true if deadline > now
131
+
132
+ @entries.delete(key)
133
+ false
134
+ end
135
+
136
+ def deadline(ttl) = now + ttl
137
+
138
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ end
140
+ end
141
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  module Mistri
6
4
  module MCP
7
5
  # A Model Context Protocol client: the initialize handshake, tools/list
@@ -20,37 +18,45 @@ module Mistri
20
18
  # re-resolving, so a host's refresh logic lives in one lambda. A session
21
19
  # the server expires (404 with a session attached) transparently
22
20
  # re-initializes, per spec.
21
+ # Remote URLs default to public HTTPS. allow_non_public: is consulted only
22
+ # for otherwise blocked addresses and plain HTTP remains loopback-only.
23
+ # max_record_bytes bounds one JSON body, SSE line, or stdio record without
24
+ # imposing a lifetime limit on an SSE stream.
23
25
  #
24
26
  # One client serializes its calls; parallel tool calls against one
25
27
  # server queue rather than interleave.
26
28
  class Client
27
29
  PROTOCOL_VERSION = "2025-11-25"
28
30
  SUPPORTED_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
29
- LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
30
-
31
31
  attr_reader :server_info
32
32
 
33
33
  def initialize(url: nil, command: nil, env: {}, token: nil, headers: {},
34
- client_name: "mistri", open_timeout: 15, read_timeout: 120)
34
+ client_name: "mistri", open_timeout: 15, read_timeout: 120,
35
+ allow_non_public: nil, max_tool_pages: 100, max_tools: 10_000,
36
+ max_record_bytes: DEFAULT_MAX_RECORD_BYTES)
35
37
  if [url, command].compact.length != 1
36
38
  raise ConfigurationError, "pass exactly one of url: or command:"
37
39
  end
38
40
 
39
- if url && token && URI(url).scheme == "http" && !LOOPBACK.include?(URI(url).host)
40
- raise ConfigurationError,
41
- "refusing to send a bearer token over plain HTTP to #{URI(url).host}"
42
- end
41
+ validate_limit(:max_tool_pages, max_tool_pages)
42
+ validate_limit(:max_tools, max_tools)
43
+ validate_limit(:max_record_bytes, max_record_bytes)
43
44
 
44
45
  @wire = if url
45
46
  Wires::Http.new(url: url, token: token, headers: headers,
46
- open_timeout: open_timeout, read_timeout: read_timeout)
47
+ open_timeout: open_timeout, read_timeout: read_timeout,
48
+ allow_non_public: allow_non_public,
49
+ max_record_bytes: max_record_bytes)
47
50
  else
48
- Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout)
51
+ Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout,
52
+ max_record_bytes: max_record_bytes)
49
53
  end
50
54
  @client_name = client_name
51
55
  @mutex = Mutex.new
52
56
  @serial = 0
53
57
  @connected = false
58
+ @max_tool_pages = max_tool_pages
59
+ @max_tools = max_tools
54
60
  end
55
61
 
56
62
  # The server's tools as it describes them: hashes with "name",
@@ -72,8 +78,7 @@ module Mistri
72
78
  end
73
79
 
74
80
  def close
75
- @wire.close
76
- @connected = false
81
+ @mutex.synchronize { reset_wire }
77
82
  nil
78
83
  end
79
84
 
@@ -96,6 +101,9 @@ module Mistri
96
101
  @server_info = result["serverInfo"]
97
102
  @wire.notify({ jsonrpc: "2.0", method: "notifications/initialized" })
98
103
  @connected = true
104
+ rescue Mistri::Error
105
+ reset_wire
106
+ raise
99
107
  end
100
108
 
101
109
  def request(method, params, reconnected: false, refreshed: false)
@@ -119,7 +127,9 @@ module Mistri
119
127
  payload = { jsonrpc: "2.0", id: id, method: method, params: params }
120
128
  result = nil
121
129
  responded = false
122
- @wire.call(payload) do |record|
130
+ replayable = method != "tools/call"
131
+ # A tool may commit its external side effect before the connection dies.
132
+ @wire.call(payload, replayable: replayable) do |record|
123
133
  next unless record.is_a?(Hash) && record["id"] == id
124
134
 
125
135
  responded = true
@@ -127,9 +137,28 @@ module Mistri
127
137
 
128
138
  result = record["result"]
129
139
  end
130
- raise Error, "the server sent no response to #{method}" unless responded
140
+ unless responded
141
+ detail = "the server sent no matching response to #{method}"
142
+ raise Error, detail if replayable
143
+
144
+ raise AmbiguousDeliveryError, "#{AmbiguousDeliveryError.default_message}: #{detail}"
145
+ end
131
146
 
132
147
  result
148
+ rescue ResponseTooLargeError, ResponseTooComplexError, WireError => e
149
+ reset_wire
150
+ raise if replayable
151
+ return result if responded
152
+
153
+ message = "the request was sent but its tool outcome could not be confirmed: " \
154
+ "#{e.message}; the operation may have completed; do not retry automatically; " \
155
+ "verify external state first"
156
+ raise AmbiguousDeliveryError, message
157
+ rescue AmbiguousDeliveryError
158
+ reset_wire
159
+ return result if responded
160
+
161
+ raise
133
162
  rescue ProviderError => e
134
163
  raise SessionExpired if e.status == 404 && @wire.session?
135
164
 
@@ -139,13 +168,39 @@ module Mistri
139
168
  def list_tools
140
169
  collected = []
141
170
  cursor = nil
142
- loop do
171
+ seen_cursors = {}
172
+ @max_tool_pages.times do
143
173
  result = request("tools/list", cursor ? { cursor: cursor } : {})
144
- collected.concat(Array(result["tools"]))
174
+ raise Error, "tools/list returned a malformed result" unless result.is_a?(Hash)
175
+
176
+ page = result["tools"]
177
+ raise Error, "tools/list returned a malformed tools collection" unless page.is_a?(Array)
178
+ if collected.length + page.length > @max_tools
179
+ raise Error, "tool discovery exceeded #{@max_tools} tools"
180
+ end
181
+
182
+ collected.concat(page)
145
183
  cursor = result["nextCursor"]
146
- break unless cursor
184
+ return collected if cursor.nil?
185
+ raise Error, "tools/list returned a malformed cursor" unless cursor.is_a?(String)
186
+ raise Error, "tools/list repeated a cursor" if seen_cursors[cursor]
187
+
188
+ seen_cursors[cursor] = true
147
189
  end
148
- collected
190
+ raise Error, "tool discovery exceeded #{@max_tool_pages} pages"
191
+ end
192
+
193
+ def validate_limit(name, value)
194
+ return if value.is_a?(Integer) && value.positive?
195
+
196
+ raise ConfigurationError, "#{name}: must be a positive integer"
197
+ end
198
+
199
+ def reset_wire
200
+ @wire.close
201
+ @wire.reset_session
202
+ @connected = false
203
+ @server_info = nil
149
204
  end
150
205
 
151
206
  def rpc_error(error)