mistri 0.1.0 → 0.2.1

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.
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
- Result = Data.define(:message, :status, :pending, :output) do
14
- def initialize(message:, status:, pending: [], output: nil)
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
 
@@ -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 { |call, result| [call, result || INTERRUPTED] }
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
- interrupted = context.signal&.aborted?
38
- results[index] = interrupted ? INTERRUPTED : run_one(call, tools_by_name, context)
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.call(call.arguments, context) }
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)
@@ -45,12 +45,53 @@ module Mistri
45
45
  @mutex.synchronize { stream_locked(path, body, headers, signal, &block) }
46
46
  end
47
47
 
48
+ # POST for Streamable-HTTP endpoints (the MCP shape) that answer either
49
+ # a JSON body or an SSE stream: yields each JSON record either way and
50
+ # returns the response headers, downcased. Retries only a dead idle
51
+ # socket that failed before any response started, so a side-effecting
52
+ # call can never run twice.
53
+ def post_either(path, body:, headers: {}, &block)
54
+ @mutex.synchronize do
55
+ retried = false
56
+ begin
57
+ started = false
58
+ response_headers = nil
59
+ connection.request(build_request(path, body, headers, streaming: true)) do |response|
60
+ started = true
61
+ raise_for_status(response)
62
+ response_headers = response.to_hash.transform_values(&:first)
63
+ read_either(response, &block)
64
+ end
65
+ response_headers
66
+ rescue IOError, SocketError, SystemCallError, Timeout::Error => e
67
+ teardown
68
+ if started || retried || e.is_a?(Timeout::Error)
69
+ raise ProviderError, "connection failed: #{e.message}"
70
+ end
71
+
72
+ retried = true
73
+ retry
74
+ end
75
+ end
76
+ end
77
+
48
78
  def close
49
79
  @mutex.synchronize { teardown }
50
80
  end
51
81
 
52
82
  private
53
83
 
84
+ def read_either(response, &block)
85
+ if response["content-type"].to_s.include?("text/event-stream")
86
+ sse = SSE.new
87
+ response.read_body { |chunk| sse.feed(chunk, &block) }
88
+ sse.finish(&block)
89
+ else
90
+ raw = response.read_body
91
+ block.call(JSON.parse(raw)) unless raw.to_s.strip.empty?
92
+ end
93
+ end
94
+
54
95
  def stream_locked(path, body, headers, signal, &block)
55
96
  retried = false
56
97
  begin
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/mistri.rb CHANGED
@@ -28,6 +28,7 @@ require_relative "mistri/skills"
28
28
  require_relative "mistri/tool_executor"
29
29
  require_relative "mistri/budget"
30
30
  require_relative "mistri/retry_policy"
31
+ require_relative "mistri/reminder"
31
32
  require_relative "mistri/compaction"
32
33
  require_relative "mistri/stores/memory"
33
34
  require_relative "mistri/stores/jsonl"
@@ -36,6 +37,7 @@ require_relative "mistri/compactor"
36
37
  require_relative "mistri/result"
37
38
  require_relative "mistri/agent"
38
39
  require_relative "mistri/sub_agent"
40
+ require_relative "mistri/mcp"
39
41
  require_relative "mistri/sinks/action_cable"
40
42
  require_relative "mistri/sinks/sse"
41
43
  require_relative "mistri/sinks/coalesced"
metadata CHANGED
@@ -1,20 +1,18 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mistri
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Muhammad Ahmed Cheema
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-07-05 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
12
  description: 'Mistri (مستری) is the fixer: an agent harness that lives inside your
14
13
  app. Durable sessions in your own database, streaming, tools, fire-and-forget human
15
14
  approval, steering, compaction, structured output, skills, and sub-agents, across
16
15
  Anthropic, OpenAI, and Gemini, with zero runtime dependencies.'
17
- email:
18
16
  executables: []
19
17
  extensions: []
20
18
  extra_rdoc_files: []
@@ -26,6 +24,9 @@ files:
26
24
  - lib/generators/mistri/install/install_generator.rb
27
25
  - lib/generators/mistri/install/templates/migration.rb.tt
28
26
  - lib/generators/mistri/install/templates/model.rb.tt
27
+ - lib/generators/mistri/mcp/mcp_generator.rb
28
+ - lib/generators/mistri/mcp/templates/migration.rb.tt
29
+ - lib/generators/mistri/mcp/templates/model.rb.tt
29
30
  - lib/mistri.rb
30
31
  - lib/mistri/abort_signal.rb
31
32
  - lib/mistri/agent.rb
@@ -36,6 +37,10 @@ files:
36
37
  - lib/mistri/edit.rb
37
38
  - lib/mistri/errors.rb
38
39
  - lib/mistri/event.rb
40
+ - lib/mistri/mcp.rb
41
+ - lib/mistri/mcp/client.rb
42
+ - lib/mistri/mcp/oauth.rb
43
+ - lib/mistri/mcp/wires.rb
39
44
  - lib/mistri/memory.rb
40
45
  - lib/mistri/message.rb
41
46
  - lib/mistri/models.rb
@@ -50,6 +55,7 @@ files:
50
55
  - lib/mistri/providers/openai.rb
51
56
  - lib/mistri/providers/openai/assembler.rb
52
57
  - lib/mistri/providers/openai/serializer.rb
58
+ - lib/mistri/reminder.rb
53
59
  - lib/mistri/result.rb
54
60
  - lib/mistri/retry_policy.rb
55
61
  - lib/mistri/schema.rb
@@ -85,14 +91,15 @@ files:
85
91
  - lib/mistri/workspace/directory.rb
86
92
  - lib/mistri/workspace/memory.rb
87
93
  - lib/mistri/workspace/single.rb
88
- homepage: https://github.com/mcheemaa/mistri
94
+ homepage: https://mistri.sh
89
95
  licenses:
90
96
  - MIT
91
97
  metadata:
92
98
  source_code_uri: https://github.com/mcheemaa/mistri
93
99
  changelog_uri: https://github.com/mcheemaa/mistri/blob/main/CHANGELOG.md
94
100
  rubygems_mfa_required: 'true'
95
- post_install_message:
101
+ documentation_uri: https://mistri.sh/docs/getting-started/
102
+ bug_tracker_uri: https://github.com/mcheemaa/mistri/issues
96
103
  rdoc_options: []
97
104
  require_paths:
98
105
  - lib
@@ -107,8 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
107
114
  - !ruby/object:Gem::Version
108
115
  version: '0'
109
116
  requirements: []
110
- rubygems_version: 3.5.22
111
- signing_key:
117
+ rubygems_version: 3.6.9
112
118
  specification_version: 4
113
119
  summary: The agent harness for Ruby applications.
114
120
  test_files: []