mistri 0.0.3 → 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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +367 -3
  4. data/lib/generators/mistri/install/install_generator.rb +54 -0
  5. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  6. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  7. data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
  8. data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
  9. data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
  10. data/lib/mistri/abort_signal.rb +63 -0
  11. data/lib/mistri/agent.rb +389 -0
  12. data/lib/mistri/budget.rb +29 -0
  13. data/lib/mistri/compaction.rb +78 -0
  14. data/lib/mistri/compactor.rb +182 -0
  15. data/lib/mistri/content.rb +89 -0
  16. data/lib/mistri/edit.rb +238 -0
  17. data/lib/mistri/errors.rb +94 -0
  18. data/lib/mistri/event.rb +54 -0
  19. data/lib/mistri/mcp/client.rb +156 -0
  20. data/lib/mistri/mcp/oauth.rb +286 -0
  21. data/lib/mistri/mcp/wires.rb +164 -0
  22. data/lib/mistri/mcp.rb +96 -0
  23. data/lib/mistri/memory.rb +26 -0
  24. data/lib/mistri/message.rb +90 -0
  25. data/lib/mistri/models.rb +43 -0
  26. data/lib/mistri/partial_json.rb +210 -0
  27. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  28. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  29. data/lib/mistri/providers/anthropic.rb +106 -0
  30. data/lib/mistri/providers/fake.rb +109 -0
  31. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  32. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  33. data/lib/mistri/providers/gemini.rb +73 -0
  34. data/lib/mistri/providers/openai/assembler.rb +205 -0
  35. data/lib/mistri/providers/openai/serializer.rb +104 -0
  36. data/lib/mistri/providers/openai.rb +72 -0
  37. data/lib/mistri/reminder.rb +36 -0
  38. data/lib/mistri/result.rb +32 -0
  39. data/lib/mistri/retry_policy.rb +47 -0
  40. data/lib/mistri/schema.rb +162 -0
  41. data/lib/mistri/session.rb +124 -0
  42. data/lib/mistri/sinks/action_cable.rb +30 -0
  43. data/lib/mistri/sinks/coalesced.rb +61 -0
  44. data/lib/mistri/sinks/sse.rb +26 -0
  45. data/lib/mistri/skill.rb +15 -0
  46. data/lib/mistri/skills.rb +81 -0
  47. data/lib/mistri/sse.rb +50 -0
  48. data/lib/mistri/stop_reason.rb +25 -0
  49. data/lib/mistri/stores/active_record.rb +47 -0
  50. data/lib/mistri/stores/jsonl.rb +37 -0
  51. data/lib/mistri/stores/memory.rb +22 -0
  52. data/lib/mistri/sub_agent.rb +211 -0
  53. data/lib/mistri/tool.rb +95 -0
  54. data/lib/mistri/tool_call.rb +18 -0
  55. data/lib/mistri/tool_context.rb +15 -0
  56. data/lib/mistri/tool_executor.rb +87 -0
  57. data/lib/mistri/tool_result.rb +23 -0
  58. data/lib/mistri/tools/edit_file.rb +37 -0
  59. data/lib/mistri/tools/find_in_file.rb +36 -0
  60. data/lib/mistri/tools/list_files.rb +16 -0
  61. data/lib/mistri/tools/read_file.rb +38 -0
  62. data/lib/mistri/tools/read_memory.rb +16 -0
  63. data/lib/mistri/tools/update_memory.rb +22 -0
  64. data/lib/mistri/tools/write_file.rb +20 -0
  65. data/lib/mistri/tools.rb +50 -0
  66. data/lib/mistri/transport.rb +228 -0
  67. data/lib/mistri/usage.rb +79 -0
  68. data/lib/mistri/version.rb +1 -1
  69. data/lib/mistri/workspace/active_record.rb +47 -0
  70. data/lib/mistri/workspace/directory.rb +52 -0
  71. data/lib/mistri/workspace/memory.rb +40 -0
  72. data/lib/mistri/workspace/single.rb +48 -0
  73. data/lib/mistri.rb +89 -0
  74. metadata +79 -10
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # One event in a streamed assistant turn. A stream is one :start, then a
5
+ # start/delta/end trio per content block (text, thinking, or toolcall), then
6
+ # exactly one terminal event: :done on success or :error on failure, carrying
7
+ # the complete message and its stop reason.
8
+ #
9
+ # `partial` is an immutable snapshot of the assistant message so far, safe to
10
+ # hold across events. `content_index` is the block's position in that
11
+ # message's content list.
12
+ # origin names the sub-agent an event came from: nil for this agent's own
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).
16
+ class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
17
+ :reason, :message, :error_message, :partial, :origin,
18
+ :duration)
19
+ # The stream types come from a provider mid-turn; the loop adds
20
+ # :tool_result after it runs each tool, :approval_needed when a gated
21
+ # call parks for a human, and :compacting/:compaction around a context
22
+ # compaction, so one subscription sees the whole exchange.
23
+ TYPES = %i[
24
+ start
25
+ text_start text_delta text_end
26
+ thinking_start thinking_delta thinking_end
27
+ toolcall_start toolcall_delta toolcall_end
28
+ done error
29
+ tool_result approval_needed
30
+ compacting compaction
31
+ retry
32
+ ].freeze
33
+
34
+ def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
35
+ reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
36
+ duration: nil)
37
+ raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
38
+
39
+ super
40
+ end
41
+
42
+ def done? = type == :done
43
+
44
+ def error? = type == :error
45
+
46
+ def terminal? = done? || error?
47
+
48
+ # Partials are ephemeral streaming state and stay out of serialization.
49
+ def to_h
50
+ { type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
51
+ reason:, message: message&.to_h, error_message:, origin:, duration: }.compact
52
+ end
53
+ end
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
@@ -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