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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +31 -0
- data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +1 -0
- data/lib/talk_to_your_app/configuration.rb +17 -0
- data/lib/talk_to_your_app/plugins/health/plugin.rb +17 -0
- data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +20 -0
- data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +72 -0
- data/lib/talk_to_your_app/version.rb +1 -1
- data/lib/talk_to_your_app.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75f1f5c1937a94473396574f7ce8d0162ee3169bfe7d4c44484ba7e7f7244be4
|
|
4
|
+
data.tar.gz: 85f09fd8619735cdb26c84d7ad7f07eeb4ff194d7084024eccdc40c9f8ec4b2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/talk_to_your_app.rb
CHANGED
|
@@ -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.
|
|
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
|