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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Mistri
|
|
10
|
+
module MCP
|
|
11
|
+
# The OAuth 2.1 subset the MCP spec requires of clients, as three
|
|
12
|
+
# storage-agnostic services a host calls from anywhere: a controller, a
|
|
13
|
+
# GraphQL mutation, a job. Each returns a string-keyed hash ready to
|
|
14
|
+
# persist on the host's own connection record.
|
|
15
|
+
#
|
|
16
|
+
# flow = Mistri::MCP::OAuth.start(url: params[:url],
|
|
17
|
+
# client_name: "Sendoso",
|
|
18
|
+
# redirect_uri: mcp_callback_url)
|
|
19
|
+
# # persist flow, redirect the user to flow["authorize_url"]
|
|
20
|
+
#
|
|
21
|
+
# tokens = Mistri::MCP::OAuth.complete(code: params[:code], **persisted)
|
|
22
|
+
# tokens = Mistri::MCP::OAuth.refresh(**persisted)
|
|
23
|
+
#
|
|
24
|
+
# Registration happens as the APPLICATION, never as the harness:
|
|
25
|
+
# client_name has no default because that identity is the host's call.
|
|
26
|
+
# Servers without dynamic registration take client_id:/client_secret:
|
|
27
|
+
# directly and skip it.
|
|
28
|
+
module OAuth
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Discover the server's authorization setup, register the application,
|
|
32
|
+
# and build the authorize URL. Returns everything the callback and
|
|
33
|
+
# refresh need: authorize_url, state, code_verifier, client_id,
|
|
34
|
+
# client_secret, token_auth_method, token_endpoint, resource,
|
|
35
|
+
# redirect_uri.
|
|
36
|
+
#
|
|
37
|
+
# With no scope given, the server's advertised scopes_supported are
|
|
38
|
+
# requested, and offline_access rides along when the authorization
|
|
39
|
+
# server supports it, which is what earns a refresh token from
|
|
40
|
+
# providers that require it.
|
|
41
|
+
def start(url:, client_name:, redirect_uri:, scope: nil,
|
|
42
|
+
client_id: nil, client_secret: nil)
|
|
43
|
+
resource = canonical(url)
|
|
44
|
+
resource_metadata = resource_metadata_for(url)
|
|
45
|
+
metadata = server_metadata(Array(resource_metadata["authorization_servers"]).first)
|
|
46
|
+
validate_endpoints(metadata)
|
|
47
|
+
registration = register(metadata, client_name, redirect_uri, client_id, client_secret)
|
|
48
|
+
verifier = SecureRandom.urlsafe_base64(48)
|
|
49
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
50
|
+
grant = { client_id: registration["client_id"], redirect_uri: redirect_uri,
|
|
51
|
+
verifier: verifier, state: state, resource: resource,
|
|
52
|
+
scope: resolve_scope(scope, resource_metadata, metadata) }
|
|
53
|
+
{
|
|
54
|
+
"authorize_url" => authorize_url(metadata, grant),
|
|
55
|
+
"state" => state, "code_verifier" => verifier,
|
|
56
|
+
"client_id" => registration["client_id"],
|
|
57
|
+
"client_secret" => registration["client_secret"],
|
|
58
|
+
"token_auth_method" => registration["token_endpoint_auth_method"],
|
|
59
|
+
"token_endpoint" => metadata.fetch("token_endpoint"),
|
|
60
|
+
"resource" => resource, "redirect_uri" => redirect_uri
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Exchange the callback's code for tokens.
|
|
65
|
+
def complete(code:, code_verifier:, client_id:, token_endpoint:, resource:,
|
|
66
|
+
redirect_uri:, client_secret: nil, token_auth_method: nil, **)
|
|
67
|
+
form = { "grant_type" => "authorization_code", "code" => code,
|
|
68
|
+
"code_verifier" => code_verifier, "client_id" => client_id,
|
|
69
|
+
"redirect_uri" => redirect_uri, "resource" => resource }
|
|
70
|
+
token_request(token_endpoint, form, client_secret, token_auth_method)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Trade a refresh token for a fresh set; OAuth 2.1 rotates refresh
|
|
74
|
+
# tokens, so persist the returned one.
|
|
75
|
+
def refresh(refresh_token:, client_id:, token_endpoint:, resource:,
|
|
76
|
+
client_secret: nil, token_auth_method: nil, **)
|
|
77
|
+
form = { "grant_type" => "refresh_token", "refresh_token" => refresh_token,
|
|
78
|
+
"client_id" => client_id, "resource" => resource }
|
|
79
|
+
token_request(token_endpoint, form, client_secret, token_auth_method)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# -- discovery ---------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
# RFC 9728: a 401's WWW-Authenticate names the resource metadata URL;
|
|
85
|
+
# servers that skip the header serve the well-known path.
|
|
86
|
+
def resource_metadata_for(url)
|
|
87
|
+
metadata_url = challenge_metadata_url(url) || well_known_resource_url(url)
|
|
88
|
+
document = get_json(metadata_url)
|
|
89
|
+
if Array(document["authorization_servers"]).empty?
|
|
90
|
+
raise Error, "#{metadata_url} names no authorization servers"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
document
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def challenge_metadata_url(url)
|
|
97
|
+
uri = URI(url)
|
|
98
|
+
response = http(uri) do |connection|
|
|
99
|
+
request = Net::HTTP::Post.new(uri)
|
|
100
|
+
request["Accept"] = "application/json, text/event-stream"
|
|
101
|
+
request["Content-Type"] = "application/json"
|
|
102
|
+
request.body = JSON.generate({ jsonrpc: "2.0", id: 0, method: "ping" })
|
|
103
|
+
connection.request(request)
|
|
104
|
+
end
|
|
105
|
+
challenge = response["WWW-Authenticate"].to_s
|
|
106
|
+
challenge[/resource_metadata="([^"]+)"/i, 1]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def well_known_resource_url(url)
|
|
110
|
+
uri = URI(url)
|
|
111
|
+
path = uri.path.chomp("/")
|
|
112
|
+
origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
113
|
+
"#{origin}/.well-known/oauth-protected-resource#{path unless path.empty?}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# RFC 8414 metadata, with the OpenID Connect path as a fallback since
|
|
117
|
+
# large providers often serve only that document.
|
|
118
|
+
def server_metadata(authority)
|
|
119
|
+
uri = URI(authority)
|
|
120
|
+
origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
121
|
+
path = uri.path.chomp("/")
|
|
122
|
+
candidates = ["#{origin}/.well-known/oauth-authorization-server#{path unless path.empty?}",
|
|
123
|
+
"#{origin}#{path}/.well-known/openid-configuration"]
|
|
124
|
+
candidates.each do |candidate|
|
|
125
|
+
document = try_json(candidate)
|
|
126
|
+
return document if document&.key?("token_endpoint")
|
|
127
|
+
end
|
|
128
|
+
raise Error, "no authorization server metadata at #{authority}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# RFC 7591 dynamic registration, as the application. Servers without a
|
|
132
|
+
# registration endpoint require a pre-registered client id. The
|
|
133
|
+
# returned hash keeps the token endpoint auth method the server
|
|
134
|
+
# granted, so token requests authenticate the way it expects.
|
|
135
|
+
def register(metadata, client_name, redirect_uri, client_id, client_secret)
|
|
136
|
+
return { "client_id" => client_id, "client_secret" => client_secret } if client_id
|
|
137
|
+
|
|
138
|
+
endpoint = metadata["registration_endpoint"]
|
|
139
|
+
unless endpoint
|
|
140
|
+
raise Error, "the server does not offer dynamic client registration; " \
|
|
141
|
+
"pass client_id:/client_secret: from a manual registration"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
registration = post_json(endpoint, {
|
|
145
|
+
"client_name" => client_name,
|
|
146
|
+
"redirect_uris" => [redirect_uri],
|
|
147
|
+
"grant_types" => %w[authorization_code refresh_token],
|
|
148
|
+
"response_types" => ["code"],
|
|
149
|
+
"token_endpoint_auth_method" => "client_secret_post"
|
|
150
|
+
})
|
|
151
|
+
{
|
|
152
|
+
"client_id" => presence(registration["client_id"]) ||
|
|
153
|
+
raise(Error, "registration returned no client_id"),
|
|
154
|
+
"client_secret" => presence(registration["client_secret"]),
|
|
155
|
+
"token_endpoint_auth_method" => presence(registration["token_endpoint_auth_method"])
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# No scope given: request what the resource advertises, and add
|
|
160
|
+
# offline_access when the authorization server supports it (that is
|
|
161
|
+
# what earns a refresh token from providers that require it). An
|
|
162
|
+
# unsupported offline_access is stripped rather than sent blind.
|
|
163
|
+
def resolve_scope(scope, resource_metadata, metadata)
|
|
164
|
+
scopes = scope.to_s.split
|
|
165
|
+
scopes = Array(resource_metadata["scopes_supported"]) if scopes.empty?
|
|
166
|
+
supported = Array(metadata["scopes_supported"])
|
|
167
|
+
if supported.include?("offline_access")
|
|
168
|
+
scopes |= ["offline_access"]
|
|
169
|
+
else
|
|
170
|
+
scopes -= ["offline_access"]
|
|
171
|
+
end
|
|
172
|
+
scopes.empty? ? nil : scopes.join(" ")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The spec requires authorization server endpoints over HTTPS;
|
|
176
|
+
# loopback stays allowed for development.
|
|
177
|
+
def validate_endpoints(metadata)
|
|
178
|
+
%w[authorization_endpoint token_endpoint registration_endpoint].each do |key|
|
|
179
|
+
value = metadata[key] or next
|
|
180
|
+
uri = URI(value)
|
|
181
|
+
next if uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(uri.host)
|
|
182
|
+
|
|
183
|
+
raise Error, "#{key} #{value} is not HTTPS"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def presence(value)
|
|
188
|
+
value.to_s.strip.empty? ? nil : value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def authorize_url(metadata, grant)
|
|
192
|
+
challenge = Digest::SHA256.base64digest(grant[:verifier]).tr("+/", "-_").delete("=")
|
|
193
|
+
params = { "response_type" => "code", "client_id" => grant[:client_id],
|
|
194
|
+
"redirect_uri" => grant[:redirect_uri], "state" => grant[:state],
|
|
195
|
+
"code_challenge" => challenge, "code_challenge_method" => "S256",
|
|
196
|
+
"resource" => grant[:resource] }
|
|
197
|
+
params["scope"] = grant[:scope] if grant[:scope]
|
|
198
|
+
endpoint = URI(metadata.fetch("authorization_endpoint"))
|
|
199
|
+
endpoint.query = [endpoint.query, URI.encode_www_form(params)].compact.join("&")
|
|
200
|
+
endpoint.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# -- plumbing ----------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
# RFC 8707 canonical form: lowercase scheme and host, no fragment.
|
|
206
|
+
def canonical(url)
|
|
207
|
+
uri = URI(url)
|
|
208
|
+
uri.fragment = nil
|
|
209
|
+
uri.scheme = uri.scheme.downcase
|
|
210
|
+
uri.host = uri.host.downcase if uri.host
|
|
211
|
+
uri.to_s
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def token_request(endpoint, form, client_secret, auth_method = nil)
|
|
215
|
+
basic = client_secret && auth_method == "client_secret_basic"
|
|
216
|
+
form = form.merge("client_secret" => client_secret) if client_secret && !basic
|
|
217
|
+
credentials = basic ? [form["client_id"], client_secret] : nil
|
|
218
|
+
payload = post_form(endpoint, form, basic_auth: credentials)
|
|
219
|
+
expires_in = payload["expires_in"]
|
|
220
|
+
{
|
|
221
|
+
"access_token" => payload.fetch("access_token"),
|
|
222
|
+
"refresh_token" => payload["refresh_token"],
|
|
223
|
+
"scope" => payload["scope"],
|
|
224
|
+
"expires_at" => expires_in ? Time.now.utc + expires_in.to_i : nil
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def get_json(url)
|
|
229
|
+
uri = URI(url)
|
|
230
|
+
response = http(uri) { |connection| connection.request(Net::HTTP::Get.new(uri)) }
|
|
231
|
+
raise Error, "GET #{url} answered #{response.code}" unless response.code.to_i == 200
|
|
232
|
+
|
|
233
|
+
JSON.parse(response.body)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def try_json(url)
|
|
237
|
+
get_json(url)
|
|
238
|
+
rescue Error, JSON::ParserError
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def post_json(url, body)
|
|
243
|
+
uri = URI(url)
|
|
244
|
+
response = http(uri) do |connection|
|
|
245
|
+
request = Net::HTTP::Post.new(uri)
|
|
246
|
+
request["Content-Type"] = "application/json"
|
|
247
|
+
request.body = JSON.generate(body)
|
|
248
|
+
connection.request(request)
|
|
249
|
+
end
|
|
250
|
+
unless %w[200 201].include?(response.code)
|
|
251
|
+
raise Error, "POST #{url} answered #{response.code}: #{response.body.to_s[0, 200]}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
JSON.parse(response.body)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def post_form(url, form, basic_auth: nil)
|
|
258
|
+
uri = URI(url)
|
|
259
|
+
response = http(uri) do |connection|
|
|
260
|
+
request = Net::HTTP::Post.new(uri)
|
|
261
|
+
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
262
|
+
request["Accept"] = "application/json"
|
|
263
|
+
request.basic_auth(*basic_auth) if basic_auth
|
|
264
|
+
request.body = URI.encode_www_form(form)
|
|
265
|
+
connection.request(request)
|
|
266
|
+
end
|
|
267
|
+
payload = begin
|
|
268
|
+
JSON.parse(response.body)
|
|
269
|
+
rescue StandardError
|
|
270
|
+
{}
|
|
271
|
+
end
|
|
272
|
+
unless response.code.to_i == 200
|
|
273
|
+
reason = payload["error_description"] || payload["error"] || response.body.to_s[0, 200]
|
|
274
|
+
raise Error, "token request failed (#{response.code}): #{reason}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
payload
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def http(uri, &)
|
|
281
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
282
|
+
open_timeout: 15, read_timeout: 30, &)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/wait"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Mistri
|
|
7
|
+
module MCP
|
|
8
|
+
# The two ways an MCP conversation travels. A wire takes one JSON-RPC
|
|
9
|
+
# payload, yields every decoded message the server sends back until the
|
|
10
|
+
# payload's own response arrives, and knows nothing about MCP semantics;
|
|
11
|
+
# the Client owns those.
|
|
12
|
+
module Wires
|
|
13
|
+
# Streamable HTTP: requests POST to one endpoint, responses arrive as
|
|
14
|
+
# JSON or an SSE stream. Sessions and bearer auth live here.
|
|
15
|
+
class Http
|
|
16
|
+
def initialize(url:, token:, headers:, open_timeout:, read_timeout:)
|
|
17
|
+
uri = URI(url)
|
|
18
|
+
@path = uri.path.empty? ? "/" : uri.path
|
|
19
|
+
@path = "#{@path}?#{uri.query}" if uri.query
|
|
20
|
+
@transport = Transport.new(origin: "#{uri.scheme}://#{uri.host}:#{uri.port}",
|
|
21
|
+
open_timeout: open_timeout, read_timeout: read_timeout)
|
|
22
|
+
@token = token
|
|
23
|
+
@headers = headers
|
|
24
|
+
@session_id = nil
|
|
25
|
+
@protocol_version = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_writer :protocol_version
|
|
29
|
+
|
|
30
|
+
def call(payload, &)
|
|
31
|
+
meta = @transport.post_either(@path, body: payload, headers: request_headers, &)
|
|
32
|
+
capture_session(meta)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def notify(payload)
|
|
37
|
+
discard = ->(_record) {}
|
|
38
|
+
@transport.post_either(@path, body: payload, headers: request_headers, &discard)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def session? = !@session_id.nil?
|
|
43
|
+
|
|
44
|
+
def refreshable? = @token.respond_to?(:call)
|
|
45
|
+
|
|
46
|
+
def reset_session = @session_id = nil
|
|
47
|
+
|
|
48
|
+
def close = @transport.close
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def request_headers
|
|
53
|
+
headers = { "Accept" => "application/json, text/event-stream" }
|
|
54
|
+
headers.merge!(@headers)
|
|
55
|
+
headers["Authorization"] = "Bearer #{resolve_token}" if @token
|
|
56
|
+
headers["Mcp-Session-Id"] = @session_id if @session_id
|
|
57
|
+
headers["MCP-Protocol-Version"] = @protocol_version if @protocol_version
|
|
58
|
+
headers
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_token
|
|
62
|
+
@token.respond_to?(:call) ? @token.call : @token
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def capture_session(meta)
|
|
66
|
+
session = meta && meta["mcp-session-id"]
|
|
67
|
+
@session_id = session if session
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Stdio: a spawned child process, one JSON-RPC message per line, with
|
|
72
|
+
# credentials in its environment, as the spec prescribes for local
|
|
73
|
+
# servers. Its stderr stays attached for honest local debugging.
|
|
74
|
+
class Stdio
|
|
75
|
+
def initialize(command:, env: {}, read_timeout: 120)
|
|
76
|
+
@command = Array(command).map(&:to_s)
|
|
77
|
+
@env = env.transform_keys(&:to_s).transform_values(&:to_s)
|
|
78
|
+
@read_timeout = read_timeout
|
|
79
|
+
@pid = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def call(payload)
|
|
83
|
+
spawn_server unless @pid
|
|
84
|
+
write(payload)
|
|
85
|
+
loop do
|
|
86
|
+
record = read_record
|
|
87
|
+
yield record
|
|
88
|
+
break if record.is_a?(Hash) && record["id"] == payload[:id]
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def notify(payload)
|
|
94
|
+
spawn_server unless @pid
|
|
95
|
+
write(payload)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def session? = false
|
|
100
|
+
|
|
101
|
+
def refreshable? = false
|
|
102
|
+
|
|
103
|
+
def reset_session = nil
|
|
104
|
+
|
|
105
|
+
def protocol_version=(_version); end
|
|
106
|
+
|
|
107
|
+
def close
|
|
108
|
+
return unless @pid
|
|
109
|
+
|
|
110
|
+
[@stdin, @stdout].each { |io| io.close unless io.closed? }
|
|
111
|
+
terminate
|
|
112
|
+
@pid = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def spawn_server
|
|
118
|
+
child_in, @stdin = IO.pipe
|
|
119
|
+
@stdout, child_out = IO.pipe
|
|
120
|
+
@pid = Process.spawn(@env, *@command, in: child_in, out: child_out)
|
|
121
|
+
child_in.close
|
|
122
|
+
child_out.close
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def write(payload)
|
|
126
|
+
@stdin.write("#{JSON.generate(payload)}\n")
|
|
127
|
+
@stdin.flush
|
|
128
|
+
rescue Errno::EPIPE
|
|
129
|
+
raise Error, "the MCP server closed its input"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The spec requires stdout to carry only protocol messages, so a
|
|
133
|
+
# line that is not one is corruption worth failing loudly on.
|
|
134
|
+
def read_record
|
|
135
|
+
loop do
|
|
136
|
+
ready = @stdout.wait_readable(@read_timeout)
|
|
137
|
+
raise Error, "timed out waiting for the MCP server" unless ready
|
|
138
|
+
|
|
139
|
+
line = @stdout.gets
|
|
140
|
+
raise Error, "the MCP server exited" if line.nil?
|
|
141
|
+
next if line.strip.empty?
|
|
142
|
+
|
|
143
|
+
return JSON.parse(line)
|
|
144
|
+
end
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
raise Error, "the MCP server wrote non-protocol output on stdout"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def terminate
|
|
150
|
+
Process.kill("TERM", @pid)
|
|
151
|
+
20.times do
|
|
152
|
+
return if Process.waitpid(@pid, Process::WNOHANG)
|
|
153
|
+
|
|
154
|
+
sleep(0.05)
|
|
155
|
+
end
|
|
156
|
+
Process.kill("KILL", @pid)
|
|
157
|
+
Process.waitpid(@pid)
|
|
158
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
data/lib/mistri/mcp.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# Bridge Model Context Protocol servers into Mistri tools: list a server's
|
|
7
|
+
# tools, hand them to an agent, and everything the harness already does
|
|
8
|
+
# composes — approval gates on third-party write tools, retries, sub-agent
|
|
9
|
+
# pools, the ui channel.
|
|
10
|
+
#
|
|
11
|
+
# client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
|
|
12
|
+
# token: -> { connection.fresh_token })
|
|
13
|
+
# agent = Mistri.agent("claude-opus-4-8",
|
|
14
|
+
# tools: Mistri::MCP.tools(client, prefix: "linear"))
|
|
15
|
+
#
|
|
16
|
+
# The bridge is duck-typed: any client responding to tools (an array of
|
|
17
|
+
# {"name", "description", "inputSchema"} hashes) and call_tool(name, args)
|
|
18
|
+
# bridges the same way, so the official mcp gem's client plugs in too.
|
|
19
|
+
module MCP
|
|
20
|
+
# A protocol-level failure: a JSON-RPC error, a missing response, an
|
|
21
|
+
# unsupported negotiation.
|
|
22
|
+
class Error < Mistri::Error
|
|
23
|
+
attr_reader :code
|
|
24
|
+
|
|
25
|
+
def initialize(message = nil, code: nil)
|
|
26
|
+
@code = code
|
|
27
|
+
super(message)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The server expired this client's session (a 404 with a session
|
|
32
|
+
# attached); the spec says start a fresh one, and Client does.
|
|
33
|
+
class SessionExpired < Error; end
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# The server's tools as Mistri tools. allow/deny filter by remote name,
|
|
38
|
+
# prefix namespaces local names ("linear__create_issue") against
|
|
39
|
+
# collisions, and gates marks tools needing human approval
|
|
40
|
+
# (gates: { "create_issue" => true }, or needs_approval: for all).
|
|
41
|
+
def tools(client, allow: nil, deny: [], prefix: nil, needs_approval: false, gates: {})
|
|
42
|
+
listed = client.tools
|
|
43
|
+
listed = listed.select { |tool| allow.include?(tool["name"]) } if allow
|
|
44
|
+
listed = listed.reject { |tool| deny.include?(tool["name"]) }
|
|
45
|
+
listed.map do |tool|
|
|
46
|
+
bridge(client, tool, prefix: prefix, gate: gates.fetch(tool["name"], needs_approval))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def bridge(client, spec, prefix: nil, gate: false)
|
|
51
|
+
remote = spec.fetch("name")
|
|
52
|
+
local = prefix ? "#{prefix}__#{remote}" : remote
|
|
53
|
+
Tool.define(local, spec["description"].to_s,
|
|
54
|
+
input_schema: spec["inputSchema"] || Tool::EMPTY_SCHEMA,
|
|
55
|
+
needs_approval: gate) do |args|
|
|
56
|
+
answer(client.call_tool(remote, args || {}))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An MCP result becomes model-readable content: text joins, images ride
|
|
61
|
+
# as image blocks, and isError answers in band so the model can react.
|
|
62
|
+
def answer(result)
|
|
63
|
+
blocks = Array(result["content"]).map { |block| convert(block) }
|
|
64
|
+
if result["isError"]
|
|
65
|
+
text = blocks.grep(String).join("\n")
|
|
66
|
+
return "MCP tool error: #{text.empty? ? "unknown error" : text}"
|
|
67
|
+
end
|
|
68
|
+
if blocks.empty? && result["structuredContent"]
|
|
69
|
+
return JSON.generate(result["structuredContent"])
|
|
70
|
+
end
|
|
71
|
+
return blocks.join("\n") if blocks.all?(String)
|
|
72
|
+
|
|
73
|
+
blocks
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def convert(block)
|
|
77
|
+
case block["type"]
|
|
78
|
+
when "text" then block["text"].to_s
|
|
79
|
+
when "image"
|
|
80
|
+
Content::Image.from_bytes(block["data"].to_s.unpack1("m"),
|
|
81
|
+
mime_type: block["mimeType"] || "image/png")
|
|
82
|
+
when "resource" then resource_text(block["resource"] || {})
|
|
83
|
+
when "resource_link" then "[resource: #{block["uri"]}]"
|
|
84
|
+
else "[unsupported #{block["type"]} content]"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resource_text(resource)
|
|
89
|
+
resource["text"] || "[resource: #{resource["uri"]}]"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
require_relative "mcp/wires"
|
|
95
|
+
require_relative "mcp/client"
|
|
96
|
+
require_relative "mcp/oauth"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# A periodic reminder for long agentic runs: models drift from their
|
|
5
|
+
# instructions as turns accumulate, and a short reminder at the tail of
|
|
6
|
+
# the context, where attention is strongest, pulls them back. It rides
|
|
7
|
+
# transform_context, so it appears fresh on the wire each time it is due
|
|
8
|
+
# and never persists to the session.
|
|
9
|
+
#
|
|
10
|
+
# agent = Mistri.agent("claude-opus-4-8", tools: tools,
|
|
11
|
+
# transform_context: Mistri::Reminder.every(
|
|
12
|
+
# 3, "Stay on gifting. Verify with tools before claiming.",
|
|
13
|
+
# ))
|
|
14
|
+
#
|
|
15
|
+
# Due is counted in completed assistant turns: the first reminder lands
|
|
16
|
+
# once `after` turns have finished (default: one full interval), then
|
|
17
|
+
# every `interval` turns.
|
|
18
|
+
class Reminder
|
|
19
|
+
def self.every(interval, text, after: nil)
|
|
20
|
+
new(interval: interval, text: text, after: after)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(interval:, text:, after: nil)
|
|
24
|
+
@interval = [interval.to_i, 1].max
|
|
25
|
+
@after = (after || @interval).to_i
|
|
26
|
+
@body = "<system-reminder>\n#{text}\n</system-reminder>"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(messages)
|
|
30
|
+
turns = messages.count(&:assistant?)
|
|
31
|
+
return messages unless turns >= @after && ((turns - @after) % @interval).zero?
|
|
32
|
+
|
|
33
|
+
messages + [Message.user(@body)]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/mistri/result.rb
CHANGED
|
@@ -9,9 +9,11 @@ module Mistri
|
|
|
9
9
|
#
|
|
10
10
|
# Reads delegate to the final message, so result.text works whether the run
|
|
11
11
|
# completed or suspended.
|
|
12
|
-
# output is a task's validated value, nil on plain runs.
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
# output is a task's validated value, nil on plain runs. usage is the
|
|
13
|
+
# run's own accounting: every persisted turn plus compaction calls, summed
|
|
14
|
+
# (a resumed run counts from the resume; task sums across its fix passes).
|
|
15
|
+
Result = Data.define(:message, :status, :pending, :output, :usage) do
|
|
16
|
+
def initialize(message:, status:, pending: [], output: nil, usage: Usage.zero)
|
|
15
17
|
super
|
|
16
18
|
end
|
|
17
19
|
|
data/lib/mistri/tool.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Mistri
|
|
|
13
13
|
# bare empty hash.
|
|
14
14
|
EMPTY_SCHEMA = { type: "object", properties: {} }.freeze
|
|
15
15
|
|
|
16
|
-
attr_reader :name, :description, :input_schema
|
|
16
|
+
attr_reader :name, :description, :input_schema, :timeout
|
|
17
17
|
|
|
18
18
|
# Define a tool. Give the argument shape as a raw JSON Schema hash via
|
|
19
19
|
# input_schema:, or build it in Ruby with a schema: block.
|
|
@@ -28,7 +28,7 @@ module Mistri
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def initialize(name:, description:, input_schema: EMPTY_SCHEMA, eager_input_streaming: false,
|
|
31
|
-
needs_approval: false, &handler)
|
|
31
|
+
needs_approval: false, timeout: nil, &handler)
|
|
32
32
|
raise ArgumentError, "tool #{name.inspect} needs a handler block" unless handler
|
|
33
33
|
|
|
34
34
|
@name = name.to_s
|
|
@@ -36,6 +36,7 @@ module Mistri
|
|
|
36
36
|
@input_schema = input_schema
|
|
37
37
|
@eager_input_streaming = eager_input_streaming
|
|
38
38
|
@needs_approval = needs_approval
|
|
39
|
+
@timeout = timeout
|
|
39
40
|
@handler = handler
|
|
40
41
|
end
|
|
41
42
|
|
data/lib/mistri/tool_executor.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
3
5
|
module Mistri
|
|
4
6
|
# Runs a turn's tool calls and returns their results in the order the model
|
|
5
7
|
# emitted them, regardless of completion order. Independent calls run
|
|
@@ -23,7 +25,10 @@ module Mistri
|
|
|
23
25
|
calls.each_with_index { |call, index| queue << [call, index] }
|
|
24
26
|
workers = max_concurrency.clamp(1, calls.length)
|
|
25
27
|
Array.new(workers) { worker(queue, results, tools_by_name, context) }.each(&:join)
|
|
26
|
-
calls.zip(results).map
|
|
28
|
+
calls.zip(results).map do |call, entry|
|
|
29
|
+
value, seconds = entry || [INTERRUPTED, nil]
|
|
30
|
+
[call, value, seconds]
|
|
31
|
+
end
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def worker(queue, results, tools_by_name, context)
|
|
@@ -34,8 +39,14 @@ module Mistri
|
|
|
34
39
|
rescue ThreadError
|
|
35
40
|
break
|
|
36
41
|
end
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
if context.signal&.aborted?
|
|
43
|
+
results[index] = [INTERRUPTED, nil]
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
value = run_one(call, tools_by_name, context)
|
|
49
|
+
results[index] = [value, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started]
|
|
39
50
|
end
|
|
40
51
|
end
|
|
41
52
|
end
|
|
@@ -44,11 +55,21 @@ module Mistri
|
|
|
44
55
|
tool = tools_by_name[call.name]
|
|
45
56
|
return "Error: unknown tool #{call.name.inspect}" unless tool
|
|
46
57
|
|
|
47
|
-
with_rails_executor { tool
|
|
58
|
+
with_rails_executor { invoke(tool, call, context) }
|
|
48
59
|
rescue StandardError => e
|
|
49
60
|
"Error running tool #{call.name.inspect}: #{e.class}: #{e.message}"
|
|
50
61
|
end
|
|
51
62
|
|
|
63
|
+
# A tool with a timeout answers in band when it stalls, so one hung
|
|
64
|
+
# handler cannot stall the whole run.
|
|
65
|
+
def invoke(tool, call, context)
|
|
66
|
+
return tool.call(call.arguments, context) unless tool.timeout
|
|
67
|
+
|
|
68
|
+
Timeout.timeout(tool.timeout) { tool.call(call.arguments, context) }
|
|
69
|
+
rescue Timeout::Error
|
|
70
|
+
"Error running tool #{call.name.inspect}: timed out after #{tool.timeout}s"
|
|
71
|
+
end
|
|
72
|
+
|
|
52
73
|
# Concurrent tools share the caller's sink; sinks are not required to be
|
|
53
74
|
# thread-safe, so forwarded events serialize here.
|
|
54
75
|
def thread_safe(emit)
|