talk_to_your_app 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 467ae958ec880ab1d8b68a96af59513b371d3ff77beca2e2a50b198d262f49e8
4
- data.tar.gz: 92bf430e1aeb96cb9bb0cb1d7719fa8c181b9f946984bd014811da2e03e574d4
3
+ metadata.gz: 75f1f5c1937a94473396574f7ce8d0162ee3169bfe7d4c44484ba7e7f7244be4
4
+ data.tar.gz: 85f09fd8619735cdb26c84d7ad7f07eeb4ff194d7084024eccdc40c9f8ec4b2b
5
5
  SHA512:
6
- metadata.gz: 3e86ab287da1da3cc7669a7bc3c71e3ba435e3a49148df21549895d1092e8165fa1ddd959f33ef2bbc2ecb24513910b4d9b6bf6019db398db529842603548de5
7
- data.tar.gz: 38994e1cc2a9ef4e935313ea520357771f23a3d281b84a1160efa7da1ef23c9080adb8c18b4c4e4045a8fbfcd34c3cf170068968429f3573b89d9bfcdb5dac4b
6
+ metadata.gz: 7efa844413ccc3376ce151e6e8a6f8376bdad560411c34cd8cd89ca93f9f5612d9438cc2ae28194a3e412fc172f1445dfdda59bcbf4b33c43d192c16366f7810
7
+ data.tar.gz: cc2ba452e469decbc311c708cef55edc482d1216a523367ebdbf1a227e6355c9342b31b02e436d7dba1fc63d77bb3767c83e1e6fc63d4aeddb48a2e3a45459d9
data/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ breaking changes.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+ - **New `health` plugin.** Exposes operator-registered health checks as MCP tools:
11
+ `health.list` (names of registered checks) and `health.run` (execute one, get
12
+ `{ name, passed, value }`). Register checks in the initializer with
13
+ `config.health_check(:name) { ... }` — the block returns a bare boolean or a
14
+ `[passed, value]` pair. A raising check is reported as a failed check
15
+ (`passed: false`, `error: "..."`) rather than a 500, matching the DB and
16
+ Flipper plugins' posture toward backend failures. No scheduling, aggregation,
17
+ or alerting in v1 — see `docs/brainstorms/talk-to-your-app-gem-v1-requirements.md`
18
+ (R20-R22), which specified this plugin as part of the original v1 scope but
19
+ was not yet implemented.
20
+
9
21
  ## [0.2.0] - 2026-09-02
10
22
 
11
23
  ### Changed
data/README.md CHANGED
@@ -178,6 +178,7 @@ All plugins are **off by default** — enable them explicitly, and an agent can
178
178
  | [Flipper](#flipper) | Read and toggle feature flags (global, actor, group, %) | `config.plugin :flipper, connection: :writer` |
179
179
  | [Rake](#rake-allow-listed-task-runner) | Run allow-listed rake tasks and read their output | `config.plugin :rake, connection: false, allowed: [...]` |
180
180
  | [Cache](#cache) | Clear the Rails cache | `config.plugin :cache, connection: false` |
181
+ | [Health](#health) | List and run operator-registered health checks | `config.plugin :health, connection: false` |
181
182
  | [Custom Tools](#custom-tools) | Call tools you write yourself (writes allowed) | `config.plugin :custom_tools, connection: false` |
182
183
 
183
184
  ### DB
@@ -314,6 +315,36 @@ config.plugin :cache, connection: false
314
315
 
315
316
  - **`cache.clear`** — calls `Rails.cache.clear` and returns `{ cleared, store }`. Every cached entry is dropped and the app re-warms from cold, so scope it to trusted principals: `config.authorize { |principal, tool, _args| tool != "cache.clear" || principal == "admin" }`.
316
317
 
318
+ ### Health
319
+
320
+ Exposes health checks you register in Ruby as MCP tools — the "is X actually working" questions that don't map onto a SQL query, a job queue, or a feature flag: is the third-party transcription API responding, are the last N video-generation jobs succeeding, is the payment webhook queue caught up.
321
+
322
+ ```ruby
323
+ config.plugin :health, connection: false
324
+
325
+ config.health_check(:video_pipeline) do
326
+ recent = VideoJob.where("created_at > ?", 15.minutes.ago)
327
+ [recent.any? && recent.all?(&:succeeded?), recent.count]
328
+ end
329
+
330
+ config.health_check(:transcription_api, timeout: 3) do
331
+ Transcription::Client.ping? # a bare boolean is fine too
332
+ end
333
+ ```
334
+
335
+ A check is a block that takes no arguments and returns either a bare boolean (pass/fail, no extra value) or a `[passed, value]` pair, where `value` is any JSON-serializable payload worth surfacing alongside the result (a count, a status string, a timestamp). Any other return shape (a bare number, `nil`, a 3-element array, ...) is rejected as a tool error rather than guessed at — silently coercing e.g. `0` or `nil` via Ruby truthiness would misreport the exact thing this tool exists to report accurately. Re-registering a name overwrites it, so re-running the initializer in a test or console session doesn't accumulate duplicates.
336
+
337
+ `timeout:` (seconds, default `10`) bounds how long `health.run` waits on the block. A check is arbitrary code that may call a third-party API — without a bound, a wedged dependency hangs the calling thread indefinitely, which on a multi-threaded Puma worker can starve the whole MCP endpoint. A timed-out check reports `passed: false` the same as any other failure.
338
+
339
+ > ⚠️ **`timeout:` is a best-effort backstop, not a hard kill — unlike Rake's subprocess `timeout:` above.** It's implemented with Ruby's `Timeout.timeout`, which can't interrupt a thread blocked inside a C extension (a stuck socket read in an HTTP client, a blocking DB driver call, a stalled DNS lookup) — exactly the shape of a real third-party API outage. Prefer the dependency's own timeout/deadline option inside the check when it has one.
340
+
341
+ > ⚠️ **The registered block is a single `Proc` invoked concurrently** by every simultaneous `health.run` call for that name. Don't lazily assign to a closed-over local (`@client ||= build_client`) inside the block — that's a data race across concurrent requests. Build any long-lived resource once, outside the block, and reference it; fetch anything request-scoped fresh inside the block.
342
+
343
+ - **`health.list`** — the names of every registered check, sorted.
344
+ - **`health.run`** — `name` (required, must be a registered check). Returns `{ name, passed, value }`. A check that raises or times out is reported as `{ passed: false, value: nil, error: "<class>" }` rather than surfacing a 500 — the same posture the DB and Flipper plugins take toward backend failures. The full exception (class **and** message) is logged server-side at `warn`; only the exception class reaches the MCP client, since the rescued code is arbitrary operator Ruby and a message can easily embed a URL, token, or internal hostname.
345
+
346
+ v1 is deliberately minimal: no scheduling, no aggregation across runs, no alerting, no historical storage. A check runs exactly when `health.run` is called and reports that one result. Wire it into your own scheduler/alerting if you want more.
347
+
317
348
  ### Custom Tools
318
349
 
319
350
  Write your own tools by subclassing `TalkToYourApp::Tool` — the same base class the bundled tools use, with typed arguments and **writes allowed** (unlike the read-only DB plugin). Drop one per file in `app/talk_to_your_app/custom_tools/` and it's exposed automatically; no explicit registration.
@@ -101,6 +101,7 @@ TalkToYourApp.configure do |config|
101
101
  # config.plugin :flipper, connection: :write # Flipper writes flag state — requires a :writing connection
102
102
  # config.plugin :rake, connection: false, allowed: ["stats", "report:generate"] # optional: timeout: 60 (per-task seconds, default 20)
103
103
  # config.plugin :cache, connection: false # cache.clear (Rails.cache.clear) — destructive, scope with config.authorize
104
+ # config.plugin :health, connection: false # health.list / health.run over config.health_check blocks
104
105
  # config.plugin :custom_tools, connection: false # or connection: :read to give tools a default
105
106
  #
106
107
  # DANGER — running :db against a writable connection. Wiring a :writing
@@ -10,6 +10,8 @@ module TalkToYourApp
10
10
  # the TalkToYourApp module, so calling `TalkToYourApp.configure` more than
11
11
  # once merges into the same instance rather than replacing it.
12
12
  class Configuration
13
+ DEFAULT_HEALTH_CHECK_TIMEOUT = 10
14
+
13
15
  # Path the MCP endpoint is mounted at in the host app's router. Default "/mcp".
14
16
  attr_accessor :mount_at
15
17
 
@@ -83,6 +85,8 @@ module TalkToYourApp
83
85
  @instructions = nil
84
86
  @connections = {}
85
87
  @enabled_plugins = {}
88
+ @health_checks = {}
89
+ @health_checks_mutex = Mutex.new
86
90
  @logger = nil
87
91
  @api_keys = {}
88
92
  @allowed_origins = []
@@ -132,6 +136,19 @@ module TalkToYourApp
132
136
  false
133
137
  end
134
138
 
139
+ def health_check(name, timeout: DEFAULT_HEALTH_CHECK_TIMEOUT, &block)
140
+ raise ArgumentError, "health_check #{name.inspect}: a block is required" unless block
141
+ unless timeout.is_a?(Numeric) && timeout.positive?
142
+ raise ArgumentError, "health_check #{name.inspect}: timeout must be a positive number, got #{timeout.inspect}"
143
+ end
144
+
145
+ @health_checks_mutex.synchronize { @health_checks[name.to_sym] = { block: block, timeout: timeout } }
146
+ end
147
+
148
+ def health_checks
149
+ @health_checks_mutex.synchronize { @health_checks.dup }
150
+ end
151
+
135
152
  # Declared named connections, keyed by gem-internal symbol name.
136
153
  attr_reader :connections
137
154
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "tools/list_checks"
5
+ require_relative "tools/run_check"
6
+
7
+ module TalkToYourApp
8
+ module Plugins
9
+ module Health
10
+ class Plugin < TalkToYourApp::Plugin
11
+ tools Tools::ListChecks, Tools::RunCheck
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ TalkToYourApp.register_plugin(:health, TalkToYourApp::Plugins::Health::Plugin)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../tool"
4
+
5
+ module TalkToYourApp
6
+ module Plugins
7
+ module Health
8
+ module Tools
9
+ class ListChecks < TalkToYourApp::Tool
10
+ name "health.list"
11
+ description "List the names of registered health checks."
12
+
13
+ def call(_args, _ctx)
14
+ json(checks: TalkToYourApp.configuration.health_checks.keys.map(&:to_s).sort)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "../../../tool"
5
+
6
+ module TalkToYourApp
7
+ module Plugins
8
+ module Health
9
+ module Tools
10
+ class RunCheck < TalkToYourApp::Tool
11
+ # Deliberately < Exception, not StandardError: Timeout.timeout raises
12
+ # wherever the block is currently executing, including inside a
13
+ # check's own `rescue StandardError`. A StandardError subclass here
14
+ # would let a broad rescue in the check's body swallow the timeout
15
+ # before we ever see it. Exception is what Ruby's own Timeout.timeout
16
+ # uses internally for the same reason, so this can't be caught by an
17
+ # ordinary application-level rescue. `ensure` blocks still run.
18
+ class HealthCheckTimeout < Exception; end
19
+ private_constant :HealthCheckTimeout
20
+
21
+ name "health.run"
22
+ description "Run a named health check and return pass/fail plus its value."
23
+ argument :name, :string, required: true, description: "Health check name, from health.list."
24
+
25
+ def call(args, _ctx)
26
+ check = TalkToYourApp.configuration.health_checks[args[:name].to_sym]
27
+ return error("Unknown health check: #{args[:name].inspect}. Call health.list for the registered names.") unless check
28
+
29
+ result = Timeout.timeout(check[:timeout], HealthCheckTimeout) { check[:block].call }
30
+ normalized = normalize(args[:name], result)
31
+ if normalized.is_a?(String)
32
+ log_failure(args[:name], normalized)
33
+ return error(normalized)
34
+ end
35
+
36
+ passed, value = normalized
37
+ json(name: args[:name], passed: passed, value: value)
38
+ rescue HealthCheckTimeout
39
+ log_failure(args[:name], "timed out after #{check[:timeout]}s")
40
+ json(name: args[:name], passed: false, value: nil, error: "timed out after #{check[:timeout]}s")
41
+ rescue StandardError => e
42
+ log_failure(args[:name], "#{e.class}: #{e.message}")
43
+ json(name: args[:name], passed: false, value: nil, error: e.class.name)
44
+ end
45
+
46
+ private
47
+
48
+ def log_failure(name, message)
49
+ TalkToYourApp.configuration.logger&.warn("talk_to_your_app: health check #{name.inspect} failed: #{message}")
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ def normalize(check_name, result)
55
+ case result
56
+ when Array
57
+ unless result.size == 2 && [true, false].include?(result[0])
58
+ return "health check #{check_name.inspect} returned an array of shape #{result.inspect} — " \
59
+ "expected exactly [passed, value] with passed a true/false."
60
+ end
61
+ result
62
+ when true, false
63
+ [result, nil]
64
+ else
65
+ "health check #{check_name.inspect} returned #{result.class}, expected a boolean or [passed, value]."
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TalkToYourApp
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -146,4 +146,5 @@ require_relative "talk_to_your_app/plugins/jobs/plugin"
146
146
  require_relative "talk_to_your_app/plugins/flipper/plugin"
147
147
  require_relative "talk_to_your_app/plugins/rake/plugin"
148
148
  require_relative "talk_to_your_app/plugins/cache/plugin"
149
+ require_relative "talk_to_your_app/plugins/health/plugin"
149
150
  require_relative "talk_to_your_app/plugins/custom_tools/plugin"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: talk_to_your_app
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Kasyanchuk
@@ -96,6 +96,9 @@ files:
96
96
  - lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb
97
97
  - lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb
98
98
  - lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb
99
+ - lib/talk_to_your_app/plugins/health/plugin.rb
100
+ - lib/talk_to_your_app/plugins/health/tools/list_checks.rb
101
+ - lib/talk_to_your_app/plugins/health/tools/run_check.rb
99
102
  - lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb
100
103
  - lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb
101
104
  - lib/talk_to_your_app/plugins/jobs/interface.rb