talk_to_your_app 0.1.0.pre.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +402 -0
- data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +39 -0
- data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +18 -0
- data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +31 -0
- data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +12 -0
- data/lib/generators/talk_to_your_app/install/install_generator.rb +27 -0
- data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +78 -0
- data/lib/talk_to_your_app/audit_logger.rb +115 -0
- data/lib/talk_to_your_app/auth/api_key.rb +29 -0
- data/lib/talk_to_your_app/auth/basic.rb +24 -0
- data/lib/talk_to_your_app/auth/middleware.rb +74 -0
- data/lib/talk_to_your_app/configuration.rb +129 -0
- data/lib/talk_to_your_app/connection_registry.rb +131 -0
- data/lib/talk_to_your_app/current.rb +14 -0
- data/lib/talk_to_your_app/custom_tool.rb +40 -0
- data/lib/talk_to_your_app/plugin.rb +59 -0
- data/lib/talk_to_your_app/plugin_registry.rb +48 -0
- data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +26 -0
- data/lib/talk_to_your_app/plugins/db/plugin.rb +57 -0
- data/lib/talk_to_your_app/plugins/db/tools/query.rb +126 -0
- data/lib/talk_to_your_app/plugins/db/tools/schema.rb +60 -0
- data/lib/talk_to_your_app/plugins/db/tools/tables.rb +28 -0
- data/lib/talk_to_your_app/plugins/flipper/plugin.rb +132 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +41 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +42 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +41 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +23 -0
- data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +33 -0
- data/lib/talk_to_your_app/plugins/health/plugin.rb +31 -0
- data/lib/talk_to_your_app/plugins/health/registry.rb +68 -0
- data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +24 -0
- data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +27 -0
- data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +122 -0
- data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +90 -0
- data/lib/talk_to_your_app/plugins/jobs/interface.rb +38 -0
- data/lib/talk_to_your_app/plugins/jobs/plugin.rb +87 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +28 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/health.rb +25 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +23 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +30 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +28 -0
- data/lib/talk_to_your_app/plugins/rake/plugin.rb +42 -0
- data/lib/talk_to_your_app/plugins/rake/tools/run.rb +56 -0
- data/lib/talk_to_your_app/railtie.rb +56 -0
- data/lib/talk_to_your_app/renderers/html_table.rb +27 -0
- data/lib/talk_to_your_app/tool.rb +204 -0
- data/lib/talk_to_your_app/transport/rails_mount.rb +46 -0
- data/lib/talk_to_your_app/version.rb +5 -0
- data/lib/talk_to_your_app.rb +124 -0
- metadata +140 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "active_support/notifications"
|
|
6
|
+
|
|
7
|
+
module TalkToYourApp
|
|
8
|
+
# Emits exactly one structured log line per tool invocation. Wrapping happens
|
|
9
|
+
# at the tool dispatch boundary, where the principal, params, plugin, tool, and
|
|
10
|
+
# timing are all in scope. Defaults to the configured logger (Rails.logger) at
|
|
11
|
+
# the plugin's level (default INFO). Re-raises on failure so the SDK still
|
|
12
|
+
# surfaces a tool error to the client.
|
|
13
|
+
module AuditLogger
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Runs the block, times it, and logs the outcome. Returns the block's value.
|
|
17
|
+
def around(tool_class:, plugin_name:, log_level:, params:)
|
|
18
|
+
started = monotonic
|
|
19
|
+
error_class = nil
|
|
20
|
+
outcome = "success"
|
|
21
|
+
begin
|
|
22
|
+
result = yield
|
|
23
|
+
outcome = "error" if result.is_a?(MCP::Tool::Response) && result.error?
|
|
24
|
+
result
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
outcome = "error"
|
|
27
|
+
error_class = e.class.name
|
|
28
|
+
raise
|
|
29
|
+
ensure
|
|
30
|
+
# A logging failure must never replace the tool's result or its
|
|
31
|
+
# exception (an exception raised in `ensure` would do exactly that), so
|
|
32
|
+
# emit defensively and swallow any logger error to $stderr.
|
|
33
|
+
begin
|
|
34
|
+
emit(
|
|
35
|
+
plugin_name: plugin_name,
|
|
36
|
+
tool_class: tool_class,
|
|
37
|
+
params: params,
|
|
38
|
+
outcome: outcome,
|
|
39
|
+
error_class: error_class,
|
|
40
|
+
duration_ms: ((monotonic - started) * 1000).round(2),
|
|
41
|
+
log_level: log_level,
|
|
42
|
+
)
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
warn("talk_to_your_app: audit logging failed: #{e.class}: #{e.message}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def emit(plugin_name:, tool_class:, params:, outcome:, error_class:, duration_ms:, log_level:)
|
|
50
|
+
fields = build_fields(plugin_name, tool_class, params, outcome, error_class, duration_ms)
|
|
51
|
+
|
|
52
|
+
# Structured event for anyone who wants to persist a full audit trail
|
|
53
|
+
# (e.g. an Activity table with IP + principal). Subscribers receive the
|
|
54
|
+
# `fields` hash as the payload. See the README "Custom audit logging".
|
|
55
|
+
ActiveSupport::Notifications.instrument("talk_to_your_app.tool_call", fields)
|
|
56
|
+
|
|
57
|
+
level = (log_level || TalkToYourApp.configuration.log_level || :info)
|
|
58
|
+
logger.public_send(level) { format_line(fields) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The structured audit payload for one tool invocation.
|
|
62
|
+
def build_fields(plugin_name, tool_class, params, outcome, error_class, duration_ms)
|
|
63
|
+
fields = {
|
|
64
|
+
ts: Time.now.utc.iso8601(3),
|
|
65
|
+
principal: TalkToYourApp::Current.principal,
|
|
66
|
+
session_id: TalkToYourApp::Current.session_id,
|
|
67
|
+
ip: TalkToYourApp::Current.ip,
|
|
68
|
+
plugin: plugin_name,
|
|
69
|
+
tool: tool_class.tool_name,
|
|
70
|
+
params: redact(tool_class, params),
|
|
71
|
+
outcome: outcome,
|
|
72
|
+
duration_ms: duration_ms,
|
|
73
|
+
}
|
|
74
|
+
fields[:error_class] = error_class if error_class
|
|
75
|
+
fields
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def format_line(fields)
|
|
79
|
+
"talk_to_your_app " + fields.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Replaces any argument declared with `redact: true` with [REDACTED].
|
|
83
|
+
def redact(tool_class, params)
|
|
84
|
+
params.each_with_object({}) do |(key, value), acc|
|
|
85
|
+
opts = tool_class.arguments[key.to_sym]
|
|
86
|
+
acc[key] = opts && opts[:redact] ? "[REDACTED]" : value
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def format_value(value)
|
|
91
|
+
case value
|
|
92
|
+
when Hash, Array then value.to_json
|
|
93
|
+
when nil then "-"
|
|
94
|
+
else value.to_s
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def logger
|
|
99
|
+
TalkToYourApp.configuration.logger || default_logger
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def default_logger
|
|
103
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
104
|
+
Rails.logger
|
|
105
|
+
else
|
|
106
|
+
require "logger"
|
|
107
|
+
@fallback_logger ||= Logger.new($stdout)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def monotonic
|
|
112
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Auth
|
|
7
|
+
# Validates a Bearer token against the configured named API keys. The key's
|
|
8
|
+
# name becomes the logged principal. Comparison is constant-time once lengths
|
|
9
|
+
# match (a length mismatch short-circuits, which is acceptable: it leaks only
|
|
10
|
+
# the key length, not its contents).
|
|
11
|
+
module ApiKey
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Returns the principal name for a matching token, or nil.
|
|
15
|
+
def principal_for(token, api_keys)
|
|
16
|
+
return nil if token.nil? || token.empty? || api_keys.nil? || api_keys.empty?
|
|
17
|
+
|
|
18
|
+
match = api_keys.find { |_name, key| secure_compare(token, key.to_s) }
|
|
19
|
+
match&.first&.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def secure_compare(given, expected)
|
|
23
|
+
return false unless given.bytesize == expected.bytesize
|
|
24
|
+
|
|
25
|
+
OpenSSL.fixed_length_secure_compare(given, expected)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Auth
|
|
7
|
+
# Validates an HTTP Basic credential by delegating to an operator-supplied
|
|
8
|
+
# callable `(username, password) -> truthy`. The gem makes no assumption
|
|
9
|
+
# about the host app's user model. The username becomes the principal.
|
|
10
|
+
module Basic
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def principal_for(encoded, callable)
|
|
14
|
+
return nil if encoded.nil? || encoded.empty? || callable.nil?
|
|
15
|
+
|
|
16
|
+
decoded = Base64.decode64(encoded)
|
|
17
|
+
username, password = decoded.split(":", 2)
|
|
18
|
+
return nil if username.nil? || username.empty?
|
|
19
|
+
|
|
20
|
+
callable.call(username, password) ? username : nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require_relative "api_key"
|
|
5
|
+
require_relative "basic"
|
|
6
|
+
require_relative "../current"
|
|
7
|
+
|
|
8
|
+
module TalkToYourApp
|
|
9
|
+
module Auth
|
|
10
|
+
# Rack middleware sitting in front of the MCP transport. It authenticates
|
|
11
|
+
# every request, establishes the per-request principal, and enforces origin
|
|
12
|
+
# validation (DNS-rebinding protection per MCP spec 2025-11-25). Requests
|
|
13
|
+
# that fail never reach the transport.
|
|
14
|
+
class Middleware
|
|
15
|
+
def initialize(app)
|
|
16
|
+
@app = app
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
config = TalkToYourApp.configuration
|
|
21
|
+
|
|
22
|
+
origin = env["HTTP_ORIGIN"]
|
|
23
|
+
return forbidden if origin && !allowed_origin?(origin, config)
|
|
24
|
+
|
|
25
|
+
principal = authenticate(env, config)
|
|
26
|
+
return unauthorized if principal.nil?
|
|
27
|
+
|
|
28
|
+
TalkToYourApp::Current.principal = principal
|
|
29
|
+
TalkToYourApp::Current.session_id = env["HTTP_MCP_SESSION_ID"]
|
|
30
|
+
TalkToYourApp::Current.ip = Rack::Request.new(env).ip
|
|
31
|
+
env["ttya.principal"] = principal
|
|
32
|
+
|
|
33
|
+
@app.call(env)
|
|
34
|
+
ensure
|
|
35
|
+
TalkToYourApp::Current.reset
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def authenticate(env, config)
|
|
41
|
+
header = env["HTTP_AUTHORIZATION"]
|
|
42
|
+
return nil if header.nil? || header.empty?
|
|
43
|
+
|
|
44
|
+
scheme, value = header.split(" ", 2)
|
|
45
|
+
case scheme&.downcase
|
|
46
|
+
when "bearer" then ApiKey.principal_for(value, config.api_keys)
|
|
47
|
+
when "basic" then Basic.principal_for(value, config.basic_auth)
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
# An operator-supplied basic_auth callable (or any validator) that
|
|
51
|
+
# raises must surface as a controlled auth failure, not a 500 leaking a
|
|
52
|
+
# stack trace to the client.
|
|
53
|
+
warn("talk_to_your_app: authentication raised: #{e.class}: #{e.message}")
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# An empty allowlist permits non-browser clients (no Origin header reaches
|
|
58
|
+
# this method). A present Origin must match the configured allowlist.
|
|
59
|
+
def allowed_origin?(origin, config)
|
|
60
|
+
config.allowed_origins.include?(origin)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def unauthorized
|
|
64
|
+
# Body deliberately does not reveal which auth scheme is configured.
|
|
65
|
+
[401, { "Content-Type" => "application/json", "WWW-Authenticate" => "Bearer" },
|
|
66
|
+
[{ error: "unauthorized" }.to_json]]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def forbidden
|
|
70
|
+
[403, { "Content-Type" => "application/json" }, [{ error: "origin not allowed" }.to_json]]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TalkToYourApp
|
|
4
|
+
# Raised at boot when required configuration is missing or contradictory.
|
|
5
|
+
# The gem is fail-closed: configuration errors surface during Rails boot,
|
|
6
|
+
# never at the first request.
|
|
7
|
+
class ConfigurationError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# The single mutable configuration object. Held as a memoized singleton on
|
|
10
|
+
# the TalkToYourApp module, so calling `TalkToYourApp.configure` more than
|
|
11
|
+
# once merges into the same instance rather than replacing it.
|
|
12
|
+
class Configuration
|
|
13
|
+
# Path the MCP endpoint is mounted at in the host app's router. Default "/mcp".
|
|
14
|
+
attr_accessor :mount_at
|
|
15
|
+
|
|
16
|
+
# MCP server identity, surfaced to clients in the `initialize` handshake
|
|
17
|
+
# (serverInfo + instructions). All optional except name/version, which
|
|
18
|
+
# default sensibly.
|
|
19
|
+
attr_accessor :server_name, :server_title, :server_version, :server_description, :instructions
|
|
20
|
+
|
|
21
|
+
# Audit logger. Defaults to Rails.logger at boot; swappable to any object
|
|
22
|
+
# implementing the Logger interface.
|
|
23
|
+
attr_accessor :logger
|
|
24
|
+
|
|
25
|
+
# Global audit log level (default :info). Overridable per plugin via the
|
|
26
|
+
# plugin DSL's `log_level`.
|
|
27
|
+
attr_accessor :log_level
|
|
28
|
+
|
|
29
|
+
# Named API keys, { "principal-name" => "secret-key" }. The name is logged
|
|
30
|
+
# as the principal. Supports multiple keys for rotation.
|
|
31
|
+
attr_accessor :api_keys
|
|
32
|
+
|
|
33
|
+
# Origins permitted for browser-originated requests (DNS-rebinding
|
|
34
|
+
# protection). Empty allowlist permits non-browser clients (no Origin).
|
|
35
|
+
attr_accessor :allowed_origins
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@mount_at = "/mcp"
|
|
39
|
+
@server_name = "talk_to_your_app"
|
|
40
|
+
@server_version = TalkToYourApp::VERSION
|
|
41
|
+
@server_title = nil
|
|
42
|
+
@server_description = nil
|
|
43
|
+
@instructions = nil
|
|
44
|
+
@connections = {}
|
|
45
|
+
@enabled_plugins = {}
|
|
46
|
+
@logger = nil
|
|
47
|
+
@api_keys = {}
|
|
48
|
+
@allowed_origins = []
|
|
49
|
+
@basic_auth = nil
|
|
50
|
+
@log_level = :info
|
|
51
|
+
@authorizer = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sets or reads the HTTP Basic auth callable. The block receives
|
|
55
|
+
# (username, password) and returns truthy to authenticate.
|
|
56
|
+
#
|
|
57
|
+
# config.basic_auth { |user, pass| User.authenticate(user, pass) }
|
|
58
|
+
def basic_auth(&block)
|
|
59
|
+
@basic_auth = block if block
|
|
60
|
+
@basic_auth
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# True when at least one authentication mechanism is configured.
|
|
64
|
+
def auth_configured?
|
|
65
|
+
api_keys.any? || !@basic_auth.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Optional per-principal tool authorization. The block receives
|
|
69
|
+
# (principal, tool_name) and returns truthy to allow the call. With no
|
|
70
|
+
# authorizer configured, every authenticated principal may call every tool.
|
|
71
|
+
#
|
|
72
|
+
# config.authorize { |principal, tool| principal == "admin" || tool.start_with?("db.") }
|
|
73
|
+
def authorize(&block)
|
|
74
|
+
@authorizer = block if block
|
|
75
|
+
@authorizer
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def authorized?(principal, tool_name)
|
|
79
|
+
return true if @authorizer.nil?
|
|
80
|
+
|
|
81
|
+
@authorizer.call(principal, tool_name)
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
# A raising authorizer denies (fail-closed), mirroring basic_auth handling.
|
|
84
|
+
warn("talk_to_your_app: authorizer raised: #{e.class}: #{e.message}")
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Declared named connections, keyed by gem-internal symbol name.
|
|
89
|
+
attr_reader :connections
|
|
90
|
+
|
|
91
|
+
# Enabled plugins, keyed by name => options hash. Plugins are off by default.
|
|
92
|
+
attr_reader :enabled_plugins
|
|
93
|
+
|
|
94
|
+
# Enables a registered plugin, with optional per-plugin options.
|
|
95
|
+
#
|
|
96
|
+
# config.plugin :db
|
|
97
|
+
# config.plugin :jobs, adapter: :sidekiq
|
|
98
|
+
def plugin(name, **options)
|
|
99
|
+
@enabled_plugins[name.to_sym] = options
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Declares a named connection plugins can reference.
|
|
103
|
+
#
|
|
104
|
+
# config.connection :replica_readonly, database: "primary", role: :reading
|
|
105
|
+
#
|
|
106
|
+
# +database+ is a database.yml config key. +role+ is :reading or :writing;
|
|
107
|
+
# a :reading connection prevents writes at the Rails layer. +replica: true+
|
|
108
|
+
# marks the connection as pointing at a replica (informational; combining it
|
|
109
|
+
# with role: :writing is rejected as nonsensical).
|
|
110
|
+
def connection(name, database:, role:, replica: false, statement_timeout: nil)
|
|
111
|
+
role = role.to_sym
|
|
112
|
+
unless %i[reading writing].include?(role)
|
|
113
|
+
raise ConfigurationError, "connection #{name.inspect}: role must be :reading or :writing, got #{role.inspect}."
|
|
114
|
+
end
|
|
115
|
+
if replica && role == :writing
|
|
116
|
+
raise ConfigurationError,
|
|
117
|
+
"connection #{name.inspect}: `replica: true` with `role: :writing` is nonsensical — a replica cannot accept writes."
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@connections[name.to_sym] = ConnectionRegistry::ConnectionSpec.new(
|
|
121
|
+
name: name.to_sym,
|
|
122
|
+
database: database.to_sym,
|
|
123
|
+
role: role,
|
|
124
|
+
replica: replica,
|
|
125
|
+
statement_timeout: statement_timeout,
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TalkToYourApp
|
|
4
|
+
# The named-connection registry. Operators declare connections in the
|
|
5
|
+
# initializer (`config.connection :name, database:, role:`); plugins reference
|
|
6
|
+
# them by the gem-internal name. The registry's job is twofold:
|
|
7
|
+
#
|
|
8
|
+
# 1. Fail closed at boot — if a plugin requires a connection that was never
|
|
9
|
+
# declared, or a declared connection points at a database.yml key that
|
|
10
|
+
# does not exist, raise during boot rather than at the first request.
|
|
11
|
+
# 2. Switch connections at call time via Rails' `connected_to`, so each tool
|
|
12
|
+
# runs against the role its plugin declared (reads on a reader, writes on
|
|
13
|
+
# a writer) without coupling plugin code to the host's database.yml.
|
|
14
|
+
module ConnectionRegistry
|
|
15
|
+
# One declared connection. `database` is a database.yml config key; `role`
|
|
16
|
+
# is :reading or :writing. A :reading connection prevents writes at the
|
|
17
|
+
# Rails layer (defense in depth on top of a genuinely read-only DB role).
|
|
18
|
+
ConnectionSpec = Struct.new(:name, :database, :role, :replica, :statement_timeout, keyword_init: true) do
|
|
19
|
+
def reading?
|
|
20
|
+
role == :reading
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def prevent_writes?
|
|
24
|
+
reading?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Guards lazy pool-class construction against concurrent first-calls.
|
|
29
|
+
BUILD_MUTEX = Mutex.new
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def specs
|
|
34
|
+
TalkToYourApp.configuration.connections
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def registered?(name)
|
|
38
|
+
specs.key?(name.to_sym)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch(name)
|
|
42
|
+
specs[name.to_sym] ||
|
|
43
|
+
raise(ConfigurationError, "talk_to_your_app: connection #{name.inspect} is not registered. " \
|
|
44
|
+
"Declare it with `config.connection #{name.inspect}, database: ..., role: ...` in your initializer.")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Fail-closed boot check. `requirements` is an array of
|
|
48
|
+
# [connection_name, requester_label] pairs gathered from enabled plugins.
|
|
49
|
+
# Raises ConfigurationError naming every missing connection and who needs it.
|
|
50
|
+
def validate!(requirements)
|
|
51
|
+
missing = requirements.reject { |name, _requester| registered?(name) }
|
|
52
|
+
unless missing.empty?
|
|
53
|
+
details = missing.map { |name, requester| "#{name.inspect} (required by #{requester})" }.join(", ")
|
|
54
|
+
raise ConfigurationError,
|
|
55
|
+
"talk_to_your_app: missing required connection(s): #{details}. " \
|
|
56
|
+
"Declare them with `config.connection ...` in config/initializers/talk_to_your_app.rb."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
requirements.each do |name, requester|
|
|
60
|
+
spec = fetch(name)
|
|
61
|
+
next if database_config_exists?(spec.database)
|
|
62
|
+
|
|
63
|
+
raise ConfigurationError,
|
|
64
|
+
"talk_to_your_app: connection #{name.inspect} (required by #{requester}) references database " \
|
|
65
|
+
"#{spec.database.inspect}, which is not configured in database.yml for the #{env_name.inspect} environment."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Runs the block against the connection declared under `name`, switched to
|
|
70
|
+
# the spec's role. The connection is yielded; the role switch is unwound
|
|
71
|
+
# when the block returns, so nothing leaks into the surrounding request.
|
|
72
|
+
def with(name)
|
|
73
|
+
spec = fetch(name)
|
|
74
|
+
klass = connection_class_for(spec)
|
|
75
|
+
klass.connected_to(role: spec.role) do
|
|
76
|
+
yield klass.connection
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Lazily builds (and caches) an abstract ActiveRecord class wired to the
|
|
81
|
+
# spec's database under its role. Cached by (database, role) so the same
|
|
82
|
+
# declared connection reuses one pool across calls. The build is guarded by a
|
|
83
|
+
# mutex so concurrent first-calls under a threaded server cannot create two
|
|
84
|
+
# pools for the same key.
|
|
85
|
+
def connection_class_for(spec)
|
|
86
|
+
cache_key = [spec.database, spec.role]
|
|
87
|
+
existing = connection_classes[cache_key]
|
|
88
|
+
return existing if existing
|
|
89
|
+
|
|
90
|
+
BUILD_MUTEX.synchronize do
|
|
91
|
+
connection_classes[cache_key] ||= build_connection_class(spec, connection_classes.size)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `connects_to` rejects anonymous classes, so each pool class gets a stable
|
|
96
|
+
# constant name. The index suffix guarantees uniqueness even when two
|
|
97
|
+
# database keys differ only in characters `\W`-normalization would collapse.
|
|
98
|
+
def build_connection_class(spec, index)
|
|
99
|
+
const_name = "Conn#{index}_#{spec.database}_#{spec.role}".gsub(/\W/, "_")
|
|
100
|
+
remove_const(const_name) if const_defined?(const_name, false)
|
|
101
|
+
klass = Class.new(ActiveRecord::Base) { self.abstract_class = true }
|
|
102
|
+
const_set(const_name, klass)
|
|
103
|
+
klass.connects_to(database: { spec.role => spec.database })
|
|
104
|
+
klass
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Test seam: drop the cached pool classes and their constants so a
|
|
108
|
+
# reconfiguration in a later test does not reuse a stale pool. Wired into
|
|
109
|
+
# TalkToYourApp.reset_configuration!.
|
|
110
|
+
def reset!
|
|
111
|
+
BUILD_MUTEX.synchronize do
|
|
112
|
+
constants(false).grep(/\AConn\d+_/).each { |const| remove_const(const) }
|
|
113
|
+
@connection_classes = {}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def database_config_exists?(db_key)
|
|
118
|
+
ActiveRecord::Base.configurations
|
|
119
|
+
.configs_for(env_name: env_name, include_hidden: true)
|
|
120
|
+
.any? { |config| config.name.to_sym == db_key.to_sym }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def env_name
|
|
124
|
+
(defined?(Rails) && Rails.respond_to?(:env) && Rails.env.to_s) || ENV["RAILS_ENV"] || "test"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def connection_classes
|
|
128
|
+
@connection_classes ||= {}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/current_attributes"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
# Per-request context, set by the auth middleware and read by tool contexts
|
|
7
|
+
# and the audit logger. The MCP SDK does not thread the Rack env through to
|
|
8
|
+
# tool invocations, so we carry the principal and session id here.
|
|
9
|
+
# ActiveSupport::CurrentAttributes is request-isolated and reset by the Rails
|
|
10
|
+
# executor between requests.
|
|
11
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
12
|
+
attribute :principal, :session_id, :ip
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tool"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
# Base class for application-defined tools. Subclass it, declare the usual
|
|
7
|
+
# Tool DSL, and enable the `:custom_tools` plugin — every subclass is then
|
|
8
|
+
# exposed over MCP automatically, no explicit registration needed.
|
|
9
|
+
#
|
|
10
|
+
# class MakeAdmin < TalkToYourApp::CustomTool
|
|
11
|
+
# name "make_admin"
|
|
12
|
+
# description "Grant admin to a user."
|
|
13
|
+
# argument :user_id, :integer, required: true
|
|
14
|
+
# def call(args, _ctx)
|
|
15
|
+
# user = User.find(args[:user_id])
|
|
16
|
+
# user.update!(admin: true)
|
|
17
|
+
# json(id: user.id, admin: user.admin)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Custom tools can do anything the host app allows (including writes), so only
|
|
22
|
+
# enable the plugin when you trust the authenticated principals — pair it with
|
|
23
|
+
# config.authorize to scope access.
|
|
24
|
+
class CustomTool < Tool
|
|
25
|
+
# Every subclass (at any depth) registers itself here, in definition order.
|
|
26
|
+
def self.inherited(subclass)
|
|
27
|
+
super
|
|
28
|
+
TalkToYourApp::CustomTool.registry << subclass
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.registry
|
|
32
|
+
@registry ||= []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Test seam: forget all registered custom tools.
|
|
36
|
+
def self.clear!
|
|
37
|
+
registry.clear
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TalkToYourApp
|
|
4
|
+
# Base class for plugins. A plugin bundles a set of tools and declares the
|
|
5
|
+
# connections and soft-dependency gems it needs. Subclasses use the class-level
|
|
6
|
+
# DSL; instances are not created — everything is declarative metadata read at
|
|
7
|
+
# boot and registration time.
|
|
8
|
+
#
|
|
9
|
+
# class TalkToYourApp::Plugins::Db < TalkToYourApp::Plugin
|
|
10
|
+
# requires_connection :replica_readonly
|
|
11
|
+
# tools DbQueryTool
|
|
12
|
+
# end
|
|
13
|
+
class Plugin
|
|
14
|
+
NOT_SET = Object.new
|
|
15
|
+
private_constant :NOT_SET
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# One or more connection names this plugin needs declared in the registry.
|
|
19
|
+
def requires_connection(*names)
|
|
20
|
+
@required_connections ||= []
|
|
21
|
+
@required_connections.concat(names.map(&:to_sym)) unless names.empty?
|
|
22
|
+
@required_connections
|
|
23
|
+
end
|
|
24
|
+
alias_method :required_connections, :requires_connection
|
|
25
|
+
|
|
26
|
+
# Declares a soft-dependency gem. `const` is the constant the gem defines
|
|
27
|
+
# (checked at boot); `gem_name` is the human gem name for the error message.
|
|
28
|
+
def requires_gem(const = NOT_SET, gem_name: nil)
|
|
29
|
+
if const == NOT_SET
|
|
30
|
+
@required_gem
|
|
31
|
+
else
|
|
32
|
+
@required_gem = { const: const.to_s, gem_name: gem_name || const.to_s.downcase }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
alias_method :required_gem, :requires_gem
|
|
36
|
+
|
|
37
|
+
# Tool classes this plugin exposes.
|
|
38
|
+
def tools(*tool_classes)
|
|
39
|
+
@tools ||= []
|
|
40
|
+
@tools.concat(tool_classes) unless tool_classes.empty?
|
|
41
|
+
@tools
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Per-plugin audit log level override (defaults to the global level).
|
|
45
|
+
def log_level(level = NOT_SET)
|
|
46
|
+
level == NOT_SET ? @log_level : (@log_level = level)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The registry name this plugin was registered under. Set by the registry.
|
|
50
|
+
attr_accessor :plugin_name
|
|
51
|
+
|
|
52
|
+
# Per-plugin boot validation hook, called with the operator's enablement
|
|
53
|
+
# options (e.g. { adapter: :sidekiq }). Override to enforce plugin-specific
|
|
54
|
+
# requirements; the default is a no-op.
|
|
55
|
+
def validate_enablement!(_options)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TalkToYourApp
|
|
4
|
+
# Module-level registry of known plugins, keyed by name. Registration is
|
|
5
|
+
# explicit (`TalkToYourApp.register_plugin(:db, Plugins::Db)`) rather than
|
|
6
|
+
# autoloaded by convention, so the contract is visible. Iteration follows
|
|
7
|
+
# registration (Hash insertion) order.
|
|
8
|
+
module PluginRegistry
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def registry
|
|
12
|
+
@registry ||= {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register(name, plugin_class)
|
|
16
|
+
plugin_class.plugin_name = name.to_sym
|
|
17
|
+
registry[name.to_sym] = plugin_class
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def [](name)
|
|
21
|
+
registry[name.to_sym]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def registered?(name)
|
|
25
|
+
registry.key?(name.to_sym)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Boot validation for the set of plugins the operator enabled: each must be
|
|
29
|
+
# registered, and any soft-dependency gem it declares must be loadable.
|
|
30
|
+
# Connection requirements are validated separately by ConnectionRegistry.
|
|
31
|
+
def validate_enabled!
|
|
32
|
+
TalkToYourApp.enabled_plugins.each do |name, plugin_class, opts|
|
|
33
|
+
unless plugin_class
|
|
34
|
+
raise ConfigurationError,
|
|
35
|
+
"talk_to_your_app: plugin #{name.inspect} is enabled but no plugin is registered under that name."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if (req = plugin_class.required_gem) && !Object.const_defined?(req[:const])
|
|
39
|
+
raise ConfigurationError,
|
|
40
|
+
"talk_to_your_app: Plugin #{name.inspect} requires the `#{req[:gem_name]}` gem in your Gemfile " \
|
|
41
|
+
"(constant #{req[:const]} is not defined)."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
plugin_class.validate_enablement!(opts)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|