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 +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +53 -0
- data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
- data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
- data/lib/mistri/agent.rb +68 -19
- data/lib/mistri/event.rb +7 -3
- data/lib/mistri/mcp/client.rb +156 -0
- data/lib/mistri/mcp/oauth.rb +286 -0
- data/lib/mistri/mcp/wires.rb +164 -0
- data/lib/mistri/mcp.rb +96 -0
- data/lib/mistri/reminder.rb +36 -0
- data/lib/mistri/result.rb +5 -3
- data/lib/mistri/tool.rb +3 -2
- data/lib/mistri/tool_executor.rb +25 -4
- data/lib/mistri/transport.rb +41 -0
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +2 -0
- metadata +13 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53547d685a896819e552e9911249b042715686f47b1c61a3e01ca2a9542f915d
|
|
4
|
+
data.tar.gz: d902f802d0272fe0ba99525946ee50169c660d43c99d33790061231854cae302
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|