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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +402 -0
  4. data/lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb +39 -0
  5. data/lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt +18 -0
  6. data/lib/generators/talk_to_your_app/health_check/health_check_generator.rb +31 -0
  7. data/lib/generators/talk_to_your_app/health_check/templates/check.rb.tt +12 -0
  8. data/lib/generators/talk_to_your_app/install/install_generator.rb +27 -0
  9. data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +78 -0
  10. data/lib/talk_to_your_app/audit_logger.rb +115 -0
  11. data/lib/talk_to_your_app/auth/api_key.rb +29 -0
  12. data/lib/talk_to_your_app/auth/basic.rb +24 -0
  13. data/lib/talk_to_your_app/auth/middleware.rb +74 -0
  14. data/lib/talk_to_your_app/configuration.rb +129 -0
  15. data/lib/talk_to_your_app/connection_registry.rb +131 -0
  16. data/lib/talk_to_your_app/current.rb +14 -0
  17. data/lib/talk_to_your_app/custom_tool.rb +40 -0
  18. data/lib/talk_to_your_app/plugin.rb +59 -0
  19. data/lib/talk_to_your_app/plugin_registry.rb +48 -0
  20. data/lib/talk_to_your_app/plugins/custom_tools/plugin.rb +26 -0
  21. data/lib/talk_to_your_app/plugins/db/plugin.rb +57 -0
  22. data/lib/talk_to_your_app/plugins/db/tools/query.rb +126 -0
  23. data/lib/talk_to_your_app/plugins/db/tools/schema.rb +60 -0
  24. data/lib/talk_to_your_app/plugins/db/tools/tables.rb +28 -0
  25. data/lib/talk_to_your_app/plugins/flipper/plugin.rb +132 -0
  26. data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +41 -0
  27. data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +42 -0
  28. data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +41 -0
  29. data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +23 -0
  30. data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +33 -0
  31. data/lib/talk_to_your_app/plugins/health/plugin.rb +31 -0
  32. data/lib/talk_to_your_app/plugins/health/registry.rb +68 -0
  33. data/lib/talk_to_your_app/plugins/health/tools/list_checks.rb +24 -0
  34. data/lib/talk_to_your_app/plugins/health/tools/run_check.rb +27 -0
  35. data/lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb +122 -0
  36. data/lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb +90 -0
  37. data/lib/talk_to_your_app/plugins/jobs/interface.rb +38 -0
  38. data/lib/talk_to_your_app/plugins/jobs/plugin.rb +87 -0
  39. data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +28 -0
  40. data/lib/talk_to_your_app/plugins/jobs/tools/health.rb +25 -0
  41. data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +23 -0
  42. data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +30 -0
  43. data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +28 -0
  44. data/lib/talk_to_your_app/plugins/rake/plugin.rb +42 -0
  45. data/lib/talk_to_your_app/plugins/rake/tools/run.rb +56 -0
  46. data/lib/talk_to_your_app/railtie.rb +56 -0
  47. data/lib/talk_to_your_app/renderers/html_table.rb +27 -0
  48. data/lib/talk_to_your_app/tool.rb +204 -0
  49. data/lib/talk_to_your_app/transport/rails_mount.rb +46 -0
  50. data/lib/talk_to_your_app/version.rb +5 -0
  51. data/lib/talk_to_your_app.rb +124 -0
  52. 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