mistri 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: 5b5526cf23888f4a5872797ce5a6ebe245c0ff1a857ba8bc717681930f8dd4b8
4
- data.tar.gz: fa3f6dee2638cbd1e9c4b60378c40b4ac5a894d3639afff157be6bb70e5fd00f
3
+ metadata.gz: 53547d685a896819e552e9911249b042715686f47b1c61a3e01ca2a9542f915d
4
+ data.tar.gz: d902f802d0272fe0ba99525946ee50169c660d43c99d33790061231854cae302
5
5
  SHA512:
6
- metadata.gz: 8cb353d464264b7d44b8c335cfa938b72136f5273f674d4517632326929216a1a93db9577fb9c75ce66f46a70fc862726ad4444dad6e0a47ba0c8d74f3356ae5
7
- data.tar.gz: e5114ba9c5404ee9698c4878599d9f2a7895e3d9c470e1874b0c377b2ac6b59ef62b541940c5798b5cb769df148f0e5a02d835b78bc75a5655905a2fd4bdfcdb
6
+ metadata.gz: c88a9318c689bd4c8dc2b010c8e9e3c939989971d27befcdc60ff45a195cbd414e25564e866d85a2b495d1a2edb515d4892e2060d204382fc93bc430797b2508
7
+ data.tar.gz: cb8011d2421bdf066a68d0ec3b76c4d6d51ee06a7ebb49479f28d22da92fa04694115f762c3e132a11c17b69c128141b2b652d7cf9148049180cd0cb0ae7aae8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.0] - 2026-07-05
9
+
10
+ - Repository hygiene: coverage floor enforced in CI (simplecov, 90% line),
11
+ contributing/security/conduct docs, issue and PR templates, Dependabot,
12
+ and rubygems documentation and bug tracker links.
13
+
14
+ - Per-tool timeouts: Tool.define(..., timeout: 30) answers in band when a
15
+ handler stalls, so one hung tool cannot stall the run.
16
+ - :tool_result events carry duration (seconds) for executed tools, feeding
17
+ latency metrics straight from any sink.
18
+
19
+ - Mistri::Reminder.every(3, text): a periodic tail reminder for long runs,
20
+ riding transform_context; due by completed assistant turns, fresh on the
21
+ wire each time, never persisted.
22
+
23
+ - Tool hooks: before_tool(call, context) blocks a call by returning the
24
+ reason as a String, answered to the model in band; it outranks the
25
+ approval gate and screens approved calls again at settle time, so an
26
+ aged approval never beats current policy. after_tool(call, result,
27
+ context) may replace a result (both channels), nil keeps it. Hooks that
28
+ raise fail safe: before blocks, after answers in band.
29
+ - transform_context accepts an array of transforms, applied in order.
30
+
31
+ - Result#usage: every run reports its own token and cost accounting,
32
+ summing persisted turns and compaction calls; task sums across its fix
33
+ passes. Hosts meter a run without walking the session.
34
+
35
+ - MCP stdio wire: Client.new(command: [...], env: {...}) spawns a local
36
+ server as a child process speaking line-delimited JSON-RPC, credentials
37
+ in its environment per spec. Dying servers and non-protocol stdout fail
38
+ loudly; close terminates the child.
39
+
40
+ - MCP connections out of the box: Mistri::MCP::OAuth.start/.complete/
41
+ .refresh are storage-agnostic services implementing the spec's OAuth 2.1
42
+ subset (challenge and well-known discovery, RFC 8414 metadata with an
43
+ OpenID fallback, dynamic client registration as the host application,
44
+ PKCE with resource indicators, rotating refresh). `rails generate
45
+ mistri:mcp YourModel` creates a host-named connection model whose rows
46
+ carry their own flow state and encrypted tokens, with connection.tools
47
+ bridging straight into an agent and refreshing ahead of expiry.
48
+
49
+ - MCP bridge: Mistri::MCP::Client speaks Streamable HTTP (initialize
50
+ handshake, tools/list with pagination, tools/call, sessions with
51
+ transparent expiry recovery, JSON or SSE responses) with zero new
52
+ dependencies. Auth is a headers hash or a token string-or-lambda; a
53
+ lambda re-resolves once on 401, so host refresh logic lives in one place.
54
+ Mistri::MCP.tools bridges any server (or any duck-typed client, the
55
+ official mcp gem included) into Mistri tools with allow/deny lists, name
56
+ prefixing, and per-tool approval gates, so a third-party write tool can
57
+ ride the human-approval arc.
58
+
8
59
  ## [0.1.0] - 2026-07-05
9
60
 
10
61
  - Live integration harness: `rake integration` runs every feature end to
@@ -175,3 +226,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
175
226
  ## [0.0.1] - 2026-07-04
176
227
 
177
228
  - Reserved the gem name.
229
+
230
+ [0.1.0]: https://github.com/mcheemaa/mistri/releases/tag/v0.1.0
data/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  <p align="center">
6
6
  <a href="https://rubygems.org/gems/mistri"><img alt="Gem Version" src="https://img.shields.io/gem/v/mistri"></a>
7
7
  <a href="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml/badge.svg"></a>
8
+ <a href="https://codecov.io/gh/mcheemaa/mistri"><img alt="Coverage" src="https://codecov.io/gh/mcheemaa/mistri/graph/badge.svg"></a>
8
9
  <a href="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
9
10
  <a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
10
11
  <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
@@ -250,6 +251,58 @@ The edit engine matches exactly, then whitespace-tolerantly; an ambiguous
250
251
  match refuses (never silently edits the wrong place), and a near-miss error
251
252
  names the closest region so the model's retry is one-shot.
252
253
 
254
+ ## MCP
255
+
256
+ Bridge any Model Context Protocol server's tools into an agent. The client
257
+ speaks Streamable HTTP with zero new dependencies; auth is a token string
258
+ or a lambda that re-resolves once on 401, so refresh logic lives in one
259
+ place. Approval gates compose: a third-party write tool can require a
260
+ human.
261
+
262
+ ```ruby
263
+ client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
264
+ token: -> { connection.bearer_token })
265
+ tools = Mistri::MCP.tools(client, prefix: "linear",
266
+ gates: { "create_issue" => true })
267
+
268
+ agent = Mistri.agent("claude-opus-4-8", tools: tools)
269
+ ```
270
+
271
+ Local stdio servers spawn as child processes, credentials in their
272
+ environment. That is also the whole "give the agent a browser" story:
273
+
274
+ ```ruby
275
+ browser = Mistri::MCP::Client.new(
276
+ command: ["npx", "-y", "@playwright/mcp@latest", "--browser", "chrome", "--headless"],
277
+ )
278
+ agent = Mistri.agent("claude-opus-4-8",
279
+ tools: Mistri::MCP.tools(browser, allow: %w[browser_navigate browser_snapshot]))
280
+ ```
281
+
282
+ For the full connect-your-tools story in Rails, generate a connection model
283
+ (name it whatever you like):
284
+
285
+ ```console
286
+ $ bin/rails generate mistri:mcp McpConnection
287
+ ```
288
+
289
+ Each row is one server connection carrying its own OAuth flow state and
290
+ encrypted tokens. The OAuth services underneath (`Mistri::MCP::OAuth.start`,
291
+ `.complete`, `.refresh`) are storage-agnostic, so the same flow works from a
292
+ controller, a GraphQL mutation, or a job. Registration happens as your
293
+ application: `client_name:` is yours to set.
294
+
295
+ ```ruby
296
+ connection, authorize_url = McpConnection.connect(
297
+ name: "Linear", url: params[:url],
298
+ client_name: "YourApp", redirect_uri: mcp_callback_url,
299
+ )
300
+ # redirect the user to authorize_url; then, in the callback:
301
+ connection = McpConnection.complete(state: params[:state], code: params[:code])
302
+
303
+ agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))
304
+ ```
305
+
253
306
  ## Streaming into Rails
254
307
 
255
308
  Sinks bridge the event stream to a transport, and compose as blocks:
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/named_base"
5
+ require "rails/generators/active_record"
6
+
7
+ module Mistri
8
+ module Generators
9
+ # bin/rails generate mistri:mcp McpConnection
10
+ #
11
+ # Creates a host-named MCP connection model and migration: each row is
12
+ # one server connection and carries its own OAuth flow state, so the
13
+ # connect/callback pair works from a controller, a GraphQL mutation, or
14
+ # anywhere else the host prefers.
15
+ class McpGenerator < Rails::Generators::NamedBase
16
+ include ActiveRecord::Generators::Migration
17
+
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ argument :name, type: :string, default: "McpConnection"
21
+
22
+ def create_model
23
+ template "model.rb.tt", File.join("app/models", class_path, "#{file_name}.rb")
24
+ end
25
+
26
+ def create_migration_file
27
+ migration_template "migration.rb.tt",
28
+ File.join(db_migrate_path, "create_#{table_name}.rb")
29
+ end
30
+
31
+ def show_wiring
32
+ say <<~NOTE
33
+
34
+ Connect (from a controller, GraphQL mutation, wherever):
35
+
36
+ connection, authorize_url = #{class_name}.connect(
37
+ name: "Linear", url: params[:url],
38
+ client_name: "YourApp", redirect_uri: mcp_callback_url,
39
+ )
40
+ redirect_to authorize_url, allow_other_host: true
41
+
42
+ Callback:
43
+
44
+ connection = #{class_name}.complete(state: params[:state], code: params[:code])
45
+
46
+ Then hand its tools to an agent:
47
+
48
+ agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))
49
+
50
+ Tokens are encrypted; run bin/rails db:encryption:init if you have
51
+ not set up Active Record encryption.
52
+
53
+ NOTE
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,27 @@
1
+ class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ t.string :name, null: false
5
+ t.string :url, null: false
6
+ t.string :status, null: false, default: "pending"
7
+
8
+ # OAuth flow state; cleared once connected.
9
+ t.string :state
10
+ t.string :code_verifier
11
+ t.string :redirect_uri
12
+
13
+ t.string :client_id
14
+ t.string :client_secret
15
+ t.string :token_endpoint
16
+ t.string :token_auth_method
17
+ t.text :access_token
18
+ t.text :refresh_token
19
+ t.datetime :expires_at
20
+ t.string :scope
21
+ t.timestamps
22
+ end
23
+
24
+ # The callback finds its pending row by state.
25
+ add_index :<%= table_name %>, :state, unique: true
26
+ end
27
+ end
@@ -0,0 +1,63 @@
1
+ # One MCP server connection: its own OAuth flow state, its tokens, and the
2
+ # bridge into agent tools. Rows with a manually supplied access_token (API
3
+ # key servers) work the same way without the OAuth columns.
4
+ class <%= class_name %> < ApplicationRecord
5
+ encrypts :access_token, :refresh_token, :client_secret
6
+
7
+ # Begin the OAuth flow: persists a pending connection and returns it with
8
+ # the URL to send the user to.
9
+ def self.connect(name:, url:, client_name:, redirect_uri:, scope: nil)
10
+ flow = Mistri::MCP::OAuth.start(url: url, client_name: client_name,
11
+ redirect_uri: redirect_uri, scope: scope)
12
+ connection = create!(name: name, url: url, status: "pending",
13
+ state: flow["state"], code_verifier: flow["code_verifier"],
14
+ client_id: flow["client_id"], client_secret: flow["client_secret"],
15
+ token_endpoint: flow["token_endpoint"],
16
+ token_auth_method: flow["token_auth_method"],
17
+ redirect_uri: flow["redirect_uri"])
18
+ [connection, flow["authorize_url"]]
19
+ end
20
+
21
+ # Finish the flow from the OAuth callback.
22
+ def self.complete(state:, code:)
23
+ connection = find_by!(state: state)
24
+ tokens = Mistri::MCP::OAuth.complete(code: code,
25
+ code_verifier: connection.code_verifier,
26
+ client_id: connection.client_id,
27
+ client_secret: connection.client_secret,
28
+ token_endpoint: connection.token_endpoint,
29
+ token_auth_method: connection.token_auth_method,
30
+ resource: connection.url,
31
+ redirect_uri: connection.redirect_uri)
32
+ connection.update!(status: "connected", state: nil, code_verifier: nil,
33
+ access_token: tokens["access_token"],
34
+ refresh_token: tokens["refresh_token"],
35
+ expires_at: tokens["expires_at"], scope: tokens["scope"])
36
+ connection
37
+ end
38
+
39
+ def client
40
+ Mistri::MCP::Client.new(url: url, token: -> { bearer_token })
41
+ end
42
+
43
+ def tools(**options)
44
+ Mistri::MCP.tools(client, **options)
45
+ end
46
+
47
+ # A fresh bearer for every request; refreshes ahead of expiry and persists
48
+ # the rotated refresh token.
49
+ def bearer_token
50
+ refresh! if refresh_token.present? && expires_at && expires_at <= 1.minute.from_now
51
+ access_token
52
+ end
53
+
54
+ def refresh!
55
+ tokens = Mistri::MCP::OAuth.refresh(refresh_token: refresh_token,
56
+ client_id: client_id, client_secret: client_secret,
57
+ token_endpoint: token_endpoint,
58
+ token_auth_method: token_auth_method, resource: url)
59
+ update!(access_token: tokens["access_token"],
60
+ refresh_token: tokens["refresh_token"] || refresh_token,
61
+ expires_at: tokens["expires_at"], scope: tokens["scope"] || scope)
62
+ end
63
+ end
data/lib/mistri/agent.rb CHANGED
@@ -22,9 +22,15 @@ module Mistri
22
22
  # skills: an array of Skill (or a directory path for Skills.load). Their
23
23
  # descriptions join the system prompt and a read_skill tool serves full
24
24
  # bodies on demand.
25
+ # before_tool and after_tool are the programmatic gates around every
26
+ # execution: before_tool(call, context) blocks a call by returning the
27
+ # reason as a String, which answers the model in band (and it runs again
28
+ # when an approved call finally executes, so a decision that aged days
29
+ # still passes current policy); after_tool(call, result, context) may
30
+ # return a replacement result, or nil to keep the original.
25
31
  def initialize(provider:, session: nil, system: nil, tools: [], budget: nil,
26
32
  max_concurrency: 4, transform_context: nil, compaction: Compaction.new,
27
- retries: RetryPolicy.new, skills: [])
33
+ retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil)
28
34
  @provider = provider
29
35
  @session = session || Session.new(store: Stores::Memory.new)
30
36
  skills = skills.is_a?(String) ? Skills.load(skills) : Array(skills)
@@ -35,9 +41,11 @@ module Mistri
35
41
 
36
42
  @budget = budget || Budget.new
37
43
  @max_concurrency = max_concurrency
38
- @transform_context = transform_context
44
+ @transform_context = Array(transform_context)
39
45
  @compaction = compaction || nil
40
46
  @retries = retries || nil
47
+ @before_tool = before_tool
48
+ @after_tool = after_tool
41
49
  end
42
50
 
43
51
  attr_reader :session
@@ -70,7 +78,8 @@ module Mistri
70
78
  pending = open.select { |approval| approval[:decision].nil? }
71
79
  if pending.any?
72
80
  return Result.new(message: nil, status: :awaiting_approval,
73
- pending: pending.map { |approval| approval[:call] })
81
+ pending: pending.map { |approval| approval[:call] },
82
+ usage: Usage.zero)
74
83
  end
75
84
 
76
85
  settle(open, signal, &emit)
@@ -89,7 +98,9 @@ module Mistri
89
98
  def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
90
99
  result = run(task_input(input, schema), images: images, signal: signal,
91
100
  output_schema: schema, &emit)
101
+ spent = result.usage
92
102
  fixes.downto(0) do |remaining|
103
+ result = result.with(usage: spent)
93
104
  return result unless result.completed?
94
105
 
95
106
  value = parse_output(result.text)
@@ -98,6 +109,7 @@ module Mistri
98
109
  raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
99
110
 
100
111
  result = run(fix_prompt(errors), signal: signal, output_schema: schema, &emit)
112
+ spent += result.usage
101
113
  end
102
114
  end
103
115
 
@@ -126,7 +138,7 @@ module Mistri
126
138
  started = monotonic_now
127
139
  loop do
128
140
  reason = @budget.exceeded(turns: turns, usage: usage, elapsed: monotonic_now - started)
129
- return stop_for_budget(reason, &emit) if reason
141
+ return stop_for_budget(reason, usage, &emit) if reason
130
142
 
131
143
  fold_steers
132
144
  compacted = auto_compact(&emit)
@@ -138,8 +150,8 @@ module Mistri
138
150
  # Any tool call the turn made must be answered or parked, or the
139
151
  # transcript is unpairable and replay fails.
140
152
  parked = last.tool_calls? ? run_tools(last, signal, &emit) : []
141
- return suspended(last, parked) if parked.any?
142
- return finished(last) if done?(last, signal)
153
+ return suspended(last, parked, usage) if parked.any?
154
+ return finished(last, usage) if done?(last, signal)
143
155
  end
144
156
  end
145
157
 
@@ -191,8 +203,9 @@ module Mistri
191
203
  # attempt is recorded as a retry entry, never as a message, so retries
192
204
  # stay invisible to the model. Only the final outcome persists.
193
205
  def run_turn(signal, output_schema = nil, &emit)
194
- history = @session.messages
195
- history = @transform_context.call(history) if @transform_context
206
+ history = @transform_context.reduce(@session.messages) do |messages, transform|
207
+ transform.call(messages)
208
+ end
196
209
  attempt = 0
197
210
  loop do
198
211
  message = @provider.stream(messages: history, system: @system,
@@ -243,7 +256,7 @@ module Mistri
243
256
  return []
244
257
  end
245
258
 
246
- parked, free = calls.partition { |call| gated?(call) }
259
+ parked, free = screen(calls, signal, &emit).partition { |call| gated?(call) }
247
260
  execute(free, signal, &emit)
248
261
  parked.each do |call|
249
262
  @session.append("approval_request", "call" => call.to_h)
@@ -254,7 +267,8 @@ module Mistri
254
267
 
255
268
  def settle(open, signal, &emit)
256
269
  approved, denied = open.partition { |approval| approval[:decision]["approved"] }
257
- execute(approved.map { |approval| approval[:call] }, signal, &emit)
270
+ cleared = screen(approved.map { |approval| approval[:call] }, signal, &emit)
271
+ execute(cleared, signal, &emit)
258
272
  denied.each do |approval|
259
273
  note = approval[:decision]["note"]
260
274
  text = "The user denied this tool call#{note ? ": #{note}" : "."}"
@@ -268,17 +282,52 @@ module Mistri
268
282
  results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
269
283
  max_concurrency: @max_concurrency,
270
284
  session: @session, emit: emit)
271
- results.each { |call, result| answer(call, result, &emit) }
285
+ context = hook_context(signal, emit)
286
+ results.each do |call, result, seconds|
287
+ result = rewrite(call, result, context) if @after_tool
288
+ answer(call, result, duration: seconds, &emit)
289
+ end
290
+ end
291
+
292
+ # A blocked call answers in band, so the model reads the reason and
293
+ # reacts; a hook that raises blocks conservatively rather than letting
294
+ # an unpoliced call through.
295
+ def screen(calls, signal, &emit)
296
+ return calls unless @before_tool
297
+
298
+ context = hook_context(signal, emit)
299
+ calls.reject do |call|
300
+ reason = begin
301
+ @before_tool.call(call, context)
302
+ rescue StandardError => e
303
+ "the before_tool hook failed: #{e.class}: #{e.message}"
304
+ end
305
+ next false unless reason.is_a?(String)
306
+
307
+ answer(call, "Blocked: #{reason}", &emit)
308
+ true
309
+ end
310
+ end
311
+
312
+ def rewrite(call, result, context)
313
+ @after_tool.call(call, result, context) || result
314
+ rescue StandardError => e
315
+ "Error in after_tool hook: #{e.class}: #{e.message}"
316
+ end
317
+
318
+ def hook_context(signal, emit)
319
+ ToolContext.new(session: @session, signal: signal, emit: emit)
272
320
  end
273
321
 
274
322
  # The tool message carries both channels; the :tool_result event exposes
275
323
  # it whole so hosts read event.message.ui for their side of the result.
276
- def answer(call, result, &emit)
324
+ def answer(call, result, duration: nil, &emit)
277
325
  content, ui = result.is_a?(ToolResult) ? [result.content, result.ui] : [result, nil]
278
326
  message = @session.append_message(Message.tool(content: content, tool_call_id: call.id,
279
327
  tool_name: call.name, ui: ui))
280
328
  text = content.is_a?(String) ? content : "[content]"
281
- emit&.call(Event.new(type: :tool_result, tool_call: call, content: text, message: message))
329
+ emit&.call(Event.new(type: :tool_result, tool_call: call, content: text,
330
+ message: message, duration: duration))
282
331
  end
283
332
 
284
333
  def gated?(call)
@@ -286,24 +335,24 @@ module Mistri
286
335
  tool ? tool.needs_approval?(call.arguments) : false
287
336
  end
288
337
 
289
- def finished(message)
338
+ def finished(message, usage)
290
339
  status = { StopReason::ABORTED => :aborted, StopReason::BUDGET => :budget,
291
340
  StopReason::ERROR => :error }.fetch(message.stop_reason, :completed)
292
- Result.new(message: message, status: status)
341
+ Result.new(message: message, status: status, usage: usage)
293
342
  end
294
343
 
295
- def suspended(message, parked)
296
- Result.new(message: message, status: :awaiting_approval, pending: parked)
344
+ def suspended(message, parked, usage)
345
+ Result.new(message: message, status: :awaiting_approval, pending: parked, usage: usage)
297
346
  end
298
347
 
299
- def stop_for_budget(reason, &emit)
348
+ def stop_for_budget(reason, usage, &emit)
300
349
  message = Message.assistant(content: "Run stopped: #{reason} budget reached.",
301
350
  stop_reason: StopReason::BUDGET,
302
351
  error_message: "budget_#{reason}")
303
352
  @session.append_message(message)
304
353
  emit&.call(Event.new(type: :error, reason: StopReason::BUDGET, message: message,
305
354
  error_message: "budget_#{reason}"))
306
- Result.new(message: message, status: :budget)
355
+ Result.new(message: message, status: :budget, usage: usage)
307
356
  end
308
357
 
309
358
  # Distinguishable from a parsed nil: JSON "null" is a valid value.
data/lib/mistri/event.rb CHANGED
@@ -11,8 +11,11 @@ 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
16
  class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
15
- :reason, :message, :error_message, :partial, :origin)
17
+ :reason, :message, :error_message, :partial, :origin,
18
+ :duration)
16
19
  # The stream types come from a provider mid-turn; the loop adds
17
20
  # :tool_result after it runs each tool, :approval_needed when a gated
18
21
  # call parks for a human, and :compacting/:compaction around a context
@@ -29,7 +32,8 @@ module Mistri
29
32
  ].freeze
30
33
 
31
34
  def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
32
- reason: nil, message: nil, error_message: nil, partial: nil, origin: nil)
35
+ reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
36
+ duration: nil)
33
37
  raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
34
38
 
35
39
  super
@@ -44,7 +48,7 @@ module Mistri
44
48
  # Partials are ephemeral streaming state and stay out of serialization.
45
49
  def to_h
46
50
  { type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
47
- reason:, message: message&.to_h, error_message:, origin: }.compact
51
+ reason:, message: message&.to_h, error_message:, origin:, duration: }.compact
48
52
  end
49
53
  end
50
54
  end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Mistri
6
+ module MCP
7
+ # A Model Context Protocol client: the initialize handshake, tools/list
8
+ # with pagination, and tools/call, over one of two wires. url: speaks
9
+ # Streamable HTTP on the same persistent transport the providers use;
10
+ # command: spawns a local stdio server with credentials in its
11
+ # environment.
12
+ #
13
+ # Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
14
+ # token: -> { connection.bearer_token })
15
+ # Mistri::MCP::Client.new(command: ["npx", "-y", "some-mcp-server"],
16
+ # env: { "API_KEY" => key })
17
+ #
18
+ # HTTP auth is a headers hash or token: a string or a callable. A
19
+ # callable resolves per request, and a 401 retries once after
20
+ # re-resolving, so a host's refresh logic lives in one lambda. A session
21
+ # the server expires (404 with a session attached) transparently
22
+ # re-initializes, per spec.
23
+ #
24
+ # One client serializes its calls; parallel tool calls against one
25
+ # server queue rather than interleave.
26
+ class Client
27
+ PROTOCOL_VERSION = "2025-06-18"
28
+ 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
+ attr_reader :server_info
32
+
33
+ def initialize(url: nil, command: nil, env: {}, token: nil, headers: {},
34
+ client_name: "mistri", open_timeout: 15, read_timeout: 120)
35
+ if [url, command].compact.length != 1
36
+ raise ConfigurationError, "pass exactly one of url: or command:"
37
+ end
38
+
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
43
+
44
+ @wire = if url
45
+ Wires::Http.new(url: url, token: token, headers: headers,
46
+ open_timeout: open_timeout, read_timeout: read_timeout)
47
+ else
48
+ Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout)
49
+ end
50
+ @client_name = client_name
51
+ @mutex = Mutex.new
52
+ @serial = 0
53
+ @connected = false
54
+ end
55
+
56
+ # The server's tools as it describes them: hashes with "name",
57
+ # "description", and "inputSchema". Cached; refresh: true re-lists.
58
+ def tools(refresh: false)
59
+ @mutex.synchronize do
60
+ @tools = nil if refresh
61
+ @tools ||= list_tools
62
+ end
63
+ end
64
+
65
+ def call_tool(name, arguments = {})
66
+ @mutex.synchronize { request("tools/call", { name: name, arguments: arguments }) }
67
+ end
68
+
69
+ def connect
70
+ @mutex.synchronize { ensure_connected }
71
+ self
72
+ end
73
+
74
+ def close
75
+ @wire.close
76
+ @connected = false
77
+ nil
78
+ end
79
+
80
+ private
81
+
82
+ def ensure_connected
83
+ return if @connected
84
+
85
+ result = rpc("initialize", {
86
+ protocolVersion: PROTOCOL_VERSION,
87
+ capabilities: {},
88
+ clientInfo: { name: @client_name, version: Mistri::VERSION }
89
+ })
90
+ version = result["protocolVersion"].to_s
91
+ unless SUPPORTED_VERSIONS.include?(version)
92
+ raise Error, "server negotiated unsupported protocol version #{version.inspect}"
93
+ end
94
+
95
+ @wire.protocol_version = version
96
+ @server_info = result["serverInfo"]
97
+ @wire.notify({ jsonrpc: "2.0", method: "notifications/initialized" })
98
+ @connected = true
99
+ end
100
+
101
+ def request(method, params, reconnected: false, refreshed: false)
102
+ ensure_connected
103
+ rpc(method, params)
104
+ rescue AuthenticationError
105
+ raise if refreshed || !@wire.refreshable?
106
+
107
+ # The token callable resolves fresh on retry; hosts refresh there.
108
+ request(method, params, reconnected: reconnected, refreshed: true)
109
+ rescue SessionExpired
110
+ raise Error, "the server expired the session twice in a row" if reconnected
111
+
112
+ @connected = false
113
+ @wire.reset_session
114
+ request(method, params, reconnected: true, refreshed: refreshed)
115
+ end
116
+
117
+ def rpc(method, params)
118
+ id = (@serial += 1)
119
+ payload = { jsonrpc: "2.0", id: id, method: method, params: params }
120
+ result = nil
121
+ responded = false
122
+ @wire.call(payload) do |record|
123
+ next unless record.is_a?(Hash) && record["id"] == id
124
+
125
+ responded = true
126
+ raise rpc_error(record["error"]) if record["error"]
127
+
128
+ result = record["result"]
129
+ end
130
+ raise Error, "the server sent no response to #{method}" unless responded
131
+
132
+ result
133
+ rescue ProviderError => e
134
+ raise SessionExpired if e.status == 404 && @wire.session?
135
+
136
+ raise
137
+ end
138
+
139
+ def list_tools
140
+ collected = []
141
+ cursor = nil
142
+ loop do
143
+ result = request("tools/list", cursor ? { cursor: cursor } : {})
144
+ collected.concat(Array(result["tools"]))
145
+ cursor = result["nextCursor"]
146
+ break unless cursor
147
+ end
148
+ collected
149
+ end
150
+
151
+ def rpc_error(error)
152
+ Error.new(error["message"] || "MCP request failed", code: error["code"])
153
+ end
154
+ end
155
+ end
156
+ end