mistri 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. metadata +38 -3
data/docs/mcp.md ADDED
@@ -0,0 +1,366 @@
1
+ # Model Context Protocol
2
+
3
+ Mistri can turn tools from a Model Context Protocol server into ordinary local
4
+ tools. The Agent's validation, approval, event, session, and result contracts
5
+ then apply to the remote call.
6
+
7
+ The built-in client supports Streamable HTTP and stdio with no additional gem
8
+ dependency. `Mistri::MCP.tools` is duck-typed, so a different client can bridge
9
+ when it responds to `tools` and `call_tool(name, arguments)` with the documented
10
+ hash shapes.
11
+
12
+ ## Transports
13
+
14
+ ### Streamable HTTP
15
+
16
+ ```ruby
17
+ client = Mistri::MCP::Client.new(
18
+ url: "https://mcp.linear.app/mcp",
19
+ token: -> { connection.bearer_token },
20
+ )
21
+
22
+ tools = Mistri::MCP.tools(
23
+ client,
24
+ prefix: "linear",
25
+ allow: %w[search_issues get_issue create_issue],
26
+ gates: { "create_issue" => true },
27
+ )
28
+
29
+ agent = Mistri.agent("claude-opus-4-8", tools: tools)
30
+ ```
31
+
32
+ `token:` accepts a String or callable. The callable resolves for every request.
33
+ After a 401, Mistri resolves it once more and retries once, so the host can put
34
+ refresh behavior behind the callable.
35
+
36
+ Use `headers:` for fixed application headers. Mistri copies them at client
37
+ construction and rejects framing plus MCP session and protocol headers. A fixed
38
+ Authorization header is permitted; when `token:` is supplied, Mistri owns
39
+ Bearer authorization and overwrites it.
40
+
41
+ One Client serializes its requests. Parallel Agent tool calls against the same
42
+ client queue rather than interleave. Use distinct clients only when the server
43
+ and host policy allow true parallel connections.
44
+
45
+ ### Stdio
46
+
47
+ ```ruby
48
+ browser = Mistri::MCP::Client.new(
49
+ command: [
50
+ "npx", "-y", "@playwright/mcp@latest",
51
+ "--browser", "chrome", "--headless",
52
+ ],
53
+ read_timeout: 180,
54
+ )
55
+
56
+ tools = Mistri::MCP.tools(
57
+ browser,
58
+ prefix: "web",
59
+ allow: %w[browser_navigate browser_snapshot],
60
+ )
61
+ ```
62
+
63
+ `command:` is an argument array, not a shell string. Pass credentials through
64
+ `env:` when the child process needs them. Mistri writes one JSON-RPC object per
65
+ line and bounds the complete record read, including the time after its first
66
+ byte.
67
+
68
+ `@latest` is convenient for local exploration. Pin a reviewed package version
69
+ in an application or deployment so the executable does not change without a
70
+ code review.
71
+
72
+ Call `client.close` when a long-lived host is finished. For stdio, it terminates
73
+ the child. For HTTP, it closes the local connection and forgets the negotiated
74
+ session, but does not send the optional MCP DELETE termination request; remote
75
+ resources expire by server policy. The discovered tool cache remains available.
76
+ `call_tool` or `tools(refresh: true)` performs a fresh handshake when needed.
77
+
78
+ ## Tool discovery
79
+
80
+ `client.tools` performs the handshake and follows `tools/list` pagination once,
81
+ then caches the result. Use `client.tools(refresh: true)` when the host decides
82
+ to accept a changed toolset.
83
+
84
+ Discovery defaults to at most 100 pages and 10,000 tools:
85
+
86
+ ```ruby
87
+ client = Mistri::MCP::Client.new(
88
+ url: server_url,
89
+ max_tool_pages: 20,
90
+ max_tools: 2_000,
91
+ )
92
+ ```
93
+
94
+ Repeated cursors, malformed collections, and exceeded limits raise instead of
95
+ looping or allocating without bound.
96
+
97
+ `Mistri::MCP.tools` filters remote names with `allow:` and `deny:`. `prefix:`
98
+ creates local names such as `linear__create_issue` while the remote call still
99
+ uses `create_issue`. Duplicate local tool names make Agent construction fail;
100
+ one server can never silently shadow another.
101
+
102
+ `needs_approval:` gates every bridged tool. `gates:` sets policy by remote name
103
+ and overrides the shared default.
104
+
105
+ ## Schema authority
106
+
107
+ Mistri validates the directly reachable portable subset described in
108
+ [Tool contracts](tool-contracts.md). The MCP server remains responsible for its
109
+ full advertised input schema, as required by the
110
+ [MCP tools contract](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
111
+
112
+ Standard assertions outside Mistri's subset, such as `minimum`, `pattern`,
113
+ `format`, and `anyOf`, bridge as server-facing guidance. An unsupported
114
+ applicator subtree remains one guidance unit; Mistri does not partially enforce
115
+ inside it and imply a stronger guarantee.
116
+
117
+ When local policy depends on the full schema, supply a complete validator:
118
+
119
+ ```ruby
120
+ tools = Mistri::MCP.tools(
121
+ client,
122
+ complete_argument_validator: lambda { |args, schema|
123
+ host_json_schema_validator.errors(args, schema)
124
+ },
125
+ )
126
+ ```
127
+
128
+ The callback must implement the whole contract and return an Array of Strings.
129
+ Core ownership and its portable checks still run first.
130
+
131
+ An argument-applicable, non-empty `patternProperties` requires this explicit
132
+ complete authority. Same-document references can remain server guidance;
133
+ external references are rejected even with a complete validator so validation
134
+ never performs hidden file or network resolution.
135
+
136
+ An omitted `inputSchema` means a closed no-argument object. Explicit null,
137
+ non-object roots, malformed keyword shapes, and an explicit older dialect are
138
+ configuration errors. Mistri compiles MCP input as JSON Schema 2020-12.
139
+
140
+ ## Results and large data
141
+
142
+ Mistri maps MCP text, images, embedded resources, and `resource_link` into its
143
+ content model. `structuredContent` is used when no ordinary content is present
144
+ and is also retained in an error explanation. On a successful result containing
145
+ both fields, ordinary content currently wins and `structuredContent` is not
146
+ preserved.
147
+
148
+ `isError` becomes `ToolResult#error?` and remains structured in events and the
149
+ session. Anthropic receives native `is_error`; Gemini receives its error result
150
+ key. OpenAI's function-result shape has no error member, so it receives the same
151
+ explanatory content without a separate flag.
152
+
153
+ Large results should live behind a server or host resource. Return an MCP
154
+ `resource_link` and a concise description instead of embedding an unbounded
155
+ payload. Mistri renders the URI for the model and never fetches it automatically;
156
+ fetching credentials, retention, and access policy belong to the host or server.
157
+
158
+ ## Response boundaries
159
+
160
+ Provider and MCP JSON bodies, individual SSE lines, and stdio records default
161
+ to an 8 MiB `max_record_bytes:` limit:
162
+
163
+ ```ruby
164
+ client = Mistri::MCP::Client.new(
165
+ url: trusted_server_url,
166
+ max_record_bytes: 16 * 1024 * 1024,
167
+ )
168
+ ```
169
+
170
+ The limit applies to one atomic record, not the lifetime of an SSE stream.
171
+ Successful HTTP bodies are counted incrementally, and a declared oversized body
172
+ fails before it is read. Requests use identity encoding so compressed expansion
173
+ cannot bypass the boundary.
174
+
175
+ Each completed SSE JSON record also receives fixed structural ceilings before
176
+ JSON parsing: 20,001 lexical tokens, 64 levels, and 64 KiB per numeric token.
177
+ These protect allocation even when a record stays under its byte limit.
178
+
179
+ Byte overflow raises `Mistri::ResponseTooLargeError`. Structural overflow raises
180
+ `Mistri::ResponseTooComplexError`. Both expose `kind` and `limit`.
181
+
182
+ Either failure resets the wire and negotiated session. The next operation must
183
+ handshake again.
184
+
185
+ ### Ambiguous tool delivery
186
+
187
+ A response boundary or wire failure after `tools/call` is different from an
188
+ ordinary read failure: the server may have received and committed the operation
189
+ before the response disappeared.
190
+
191
+ When no matching result was confirmed, Mistri raises
192
+ `Mistri::AmbiguousDeliveryError` and never replays the call automatically. The
193
+ message tells the caller to verify external state before any retry.
194
+
195
+ That is the direct Client contract. A tool bridged through `Mistri::MCP.tools`
196
+ runs inside the normal tool executor, which converts the raised exception into
197
+ an error-marked tool result for the model and host. The transport still does not
198
+ replay the call. A model may choose to issue a new call after reading the error,
199
+ so approval, idempotency, and reconciliation remain host policy.
200
+
201
+ If a matching result arrived before an invalid trailing SSE record, that result
202
+ remains authoritative. The wire is still reset for the next operation.
203
+
204
+ Session-expired and authentication retries occur only through explicit server
205
+ responses that establish the request was not accepted under the old session or
206
+ credential. An ambiguous outcome never takes those replay paths.
207
+
208
+ ## Remote network policy
209
+
210
+ Remote MCP and server-side OAuth URLs are untrusted input. By default Mistri:
211
+
212
+ - requires public HTTPS;
213
+ - resolves and validates every DNS answer against special-purpose address
214
+ ranges, including embedded NAT64 destinations;
215
+ - rejects the whole result if any answer is unsafe;
216
+ - pins an approved address while preserving the hostname for Host, SNI, and
217
+ certificate verification;
218
+ - resolves and validates again for each new connection cycle, including an
219
+ internal keep-alive reconnect;
220
+ - tries alternate approved addresses only before request bytes are sent;
221
+ - follows no redirects;
222
+ - ignores ambient HTTP proxy settings.
223
+
224
+ Private HTTPS servers require one narrow host callback:
225
+
226
+ ```ruby
227
+ require "ipaddr"
228
+
229
+ private_range = IPAddr.new("10.20.0.0/16")
230
+ allow_internal = lambda do |uri, address|
231
+ %w[mcp.internal.example auth.internal.example].include?(uri.hostname) &&
232
+ private_range.include?(address)
233
+ end
234
+
235
+ client = Mistri::MCP::Client.new(
236
+ url: "https://mcp.internal.example/mcp",
237
+ allow_non_public: allow_internal,
238
+ )
239
+ ```
240
+
241
+ The callback is consulted only for otherwise blocked addresses. Match both the
242
+ expected hostname and an explicit IP range. It cannot permit an invalid URL.
243
+ Plain HTTP remains limited to an explicitly approved address that actually
244
+ resolves to loopback, which is suitable only for local development:
245
+
246
+ ```ruby
247
+ allow_loopback = ->(_uri, address) { address.loopback? }
248
+ ```
249
+
250
+ Pass the same policy to the Client and every OAuth operation for an internal
251
+ deployment.
252
+
253
+ ## OAuth
254
+
255
+ `Mistri::MCP::OAuth` implements the storage-agnostic OAuth 2.1 flow required by
256
+ MCP. The host owns the connection record, callback route, token encryption, and
257
+ authorization-server selection.
258
+
259
+ Start discovery and registration:
260
+
261
+ ```ruby
262
+ flow = Mistri::MCP::OAuth.start(
263
+ url: server_url,
264
+ client_name: "YourApp",
265
+ redirect_uri: callback_url,
266
+ scope: requested_scope,
267
+ allow_non_public: allow_internal,
268
+ )
269
+
270
+ # Persist the returned flow, then send the user's browser to this URL.
271
+ authorize_url = flow.fetch("authorize_url")
272
+ ```
273
+
274
+ Persist at least the state, code verifier, client ID and secret, token auth
275
+ method, issuer, resource, and redirect URI. `token_endpoint` is returned for
276
+ compatibility and display only; later operations rediscover it from the exact
277
+ issuer.
278
+
279
+ The host must compare callback state with the persisted value before exchange:
280
+
281
+ ```ruby
282
+ unless host_secure_compare(callback_state, flow.fetch("state"))
283
+ raise "invalid OAuth state"
284
+ end
285
+
286
+ tokens = Mistri::MCP::OAuth.complete(
287
+ code: params.fetch(:code),
288
+ code_verifier: flow.fetch("code_verifier"),
289
+ client_id: flow.fetch("client_id"),
290
+ client_secret: flow["client_secret"],
291
+ token_auth_method: flow.fetch("token_auth_method"),
292
+ issuer: flow.fetch("issuer"),
293
+ resource: flow.fetch("resource"),
294
+ redirect_uri: flow.fetch("redirect_uri"),
295
+ allow_non_public: allow_internal,
296
+ )
297
+ ```
298
+
299
+ Refresh tokens can rotate, so persist a returned replacement:
300
+
301
+ ```ruby
302
+ tokens = Mistri::MCP::OAuth.refresh(
303
+ refresh_token: connection.refresh_token,
304
+ client_id: connection.client_id,
305
+ client_secret: connection.client_secret,
306
+ token_auth_method: connection.token_auth_method,
307
+ issuer: connection.issuer,
308
+ resource: connection.url,
309
+ allow_non_public: allow_internal,
310
+ )
311
+ ```
312
+
313
+ An authorization server may omit `refresh_token` or `scope` from a successful
314
+ refresh. Low-level callers must retain the current stored value unless the
315
+ response supplies a replacement. The generated Active Record adapter already
316
+ does this.
317
+
318
+ Mistri requires S256 PKCE. It follows protected-resource and authorization-server
319
+ discovery, binds metadata to exact resource and issuer identifiers, and honors
320
+ challenge scope. It does not add `offline_access` as gem policy.
321
+
322
+ When discovery advertises multiple authorization servers, the host must pass
323
+ `issuer:` explicitly. A pre-registered `client_id` always requires the exact
324
+ issuer trusted when it was provisioned. Servers without dynamic registration
325
+ also accept `client_secret:` and an explicit supported `token_auth_method:`.
326
+
327
+ OAuth response bodies are uncompressed and bounded to 256 KiB. Provider error
328
+ descriptions are not copied into credential-bearing exceptions.
329
+
330
+ ## Optional Rails connection generator
331
+
332
+ Rails applications can generate an encrypted connection model and migration:
333
+
334
+ ```console
335
+ $ bin/rails generate mistri:mcp McpConnection
336
+ ```
337
+
338
+ Each row stores one server, OAuth flow state, issuer, encrypted tokens, expiry,
339
+ and scope. The generated model exposes `connect`, `complete`, token refresh, a
340
+ Client, and bridged tools. Set up Active Record encryption before storing
341
+ credentials.
342
+
343
+ Override the generated class policy when the server is internal:
344
+
345
+ ```ruby
346
+ class McpConnection < ApplicationRecord
347
+ def self.mcp_allow_non_public
348
+ ALLOW_INTERNAL_MCP
349
+ end
350
+ end
351
+ ```
352
+
353
+ The generator is an adapter, not a requirement. The same OAuth services work in
354
+ Sinatra, Hanami, GraphQL, jobs, or any host with a durable record and callback
355
+ route.
356
+
357
+ Generated application files are copies and do not change when the gem updates.
358
+ Read the [upgrade guide](../UPGRADING.md) before upgrading an existing generated
359
+ connection model.
360
+
361
+ ## Related guides
362
+
363
+ - [Tool contracts](tool-contracts.md)
364
+ - [Sessions and control](sessions.md)
365
+ - [Reliability](reliability.md)
366
+ - [Upgrading](../UPGRADING.md)