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,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../../../tool"
|
|
5
|
+
|
|
6
|
+
module TalkToYourApp
|
|
7
|
+
module Plugins
|
|
8
|
+
module Rake
|
|
9
|
+
module Tools
|
|
10
|
+
# Runs a single allow-listed rake task and returns its status and output
|
|
11
|
+
# as JSON. The task must be on the plugin's `allowed:` list; anything
|
|
12
|
+
# else is refused without executing. The task runs in a subprocess
|
|
13
|
+
# (`bundle exec rake`), so it cannot inject shell commands and its real
|
|
14
|
+
# exit status is captured.
|
|
15
|
+
class Run < TalkToYourApp::Tool
|
|
16
|
+
Result = Struct.new(:stdout, :stderr, :exit_code, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
name "rake.run"
|
|
19
|
+
description "Run an allow-listed rake task and return its status and output."
|
|
20
|
+
argument :task, :string, required: true, description: "Rake task name (must be allow-listed), e.g. \"stats\"."
|
|
21
|
+
argument :args, :array, description: "Positional task arguments, e.g. [\"42\"] for task[42]."
|
|
22
|
+
|
|
23
|
+
def call(args, _ctx)
|
|
24
|
+
task = args[:task].to_s
|
|
25
|
+
unless TalkToYourApp::Plugins::Rake.allowed?(task)
|
|
26
|
+
return error("Rake task #{task.inspect} is not allowed. Allowed tasks: #{TalkToYourApp::Plugins::Rake.allowed_tasks.inspect}.")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
result = self.class.run_task(task, Array(args[:args]).map(&:to_s))
|
|
30
|
+
json(
|
|
31
|
+
task: task,
|
|
32
|
+
status: result.exit_code.zero? ? "success" : "error",
|
|
33
|
+
exit_code: result.exit_code,
|
|
34
|
+
output: result.stdout.strip,
|
|
35
|
+
error: (result.stderr.strip.empty? ? nil : result.stderr.strip),
|
|
36
|
+
)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
error("Failed to run rake #{args[:task]}: #{e.class}: #{e.message}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Executes `bundle exec rake <task>[args]` in the app root. Array argv
|
|
42
|
+
# (no shell) so task/args cannot inject shell commands.
|
|
43
|
+
def self.run_task(task, rake_args)
|
|
44
|
+
invocation = rake_args.empty? ? task : "#{task}[#{rake_args.join(",")}]"
|
|
45
|
+
stdout, stderr, status = Open3.capture3("bundle", "exec", "rake", invocation, chdir: app_root)
|
|
46
|
+
Result.new(stdout: stdout, stderr: stderr, exit_code: status.exitstatus || 1)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.app_root
|
|
50
|
+
defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.to_s : Dir.pwd
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
# Wires the gem into the Rails boot sequence. The headline behavior is the
|
|
7
|
+
# fail-closed validation initializer: every required piece of configuration is
|
|
8
|
+
# checked once, at the end of boot, so misconfiguration surfaces at deploy
|
|
9
|
+
# time rather than on the first MCP request.
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
# The gem owns two subdirs of app/talk_to_your_app/ and loads them itself
|
|
12
|
+
# (custom_tools/ files it requires; health/ files define no constant — they
|
|
13
|
+
# call Health.register). Tell every autoloader to ignore just those two
|
|
14
|
+
# subdirs before it is set up, so eager loading (production, zeitwerk:check)
|
|
15
|
+
# doesn't expect constants that aren't there. Anything else a host places
|
|
16
|
+
# under app/talk_to_your_app/ is left to Zeitwerk to autoload normally.
|
|
17
|
+
initializer "talk_to_your_app.ignore_app_dir", before: :set_autoload_paths do |app|
|
|
18
|
+
next unless ::Rails.respond_to?(:autoloaders)
|
|
19
|
+
|
|
20
|
+
%w[custom_tools health].each do |subdir|
|
|
21
|
+
dir = app.root.join(TalkToYourApp::APP_DIR, subdir)
|
|
22
|
+
::Rails.autoloaders.each { |loader| loader.ignore(dir) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs after the host app's config/initializers (where the operator calls
|
|
27
|
+
# TalkToYourApp.configure) have loaded.
|
|
28
|
+
initializer "talk_to_your_app.validate", after: :load_config_initializers do
|
|
29
|
+
config.after_initialize do
|
|
30
|
+
TalkToYourApp.configuration.logger ||= Rails.logger
|
|
31
|
+
TalkToYourApp::Railtie.validate_boot!
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Aggregated fail-closed validation. With nothing enabled (the default),
|
|
36
|
+
# validation is a no-op and the gem boots silently. Later units add auth and
|
|
37
|
+
# plugin checks.
|
|
38
|
+
def self.validate_boot!
|
|
39
|
+
TalkToYourApp::PluginRegistry.validate_enabled!
|
|
40
|
+
TalkToYourApp::ConnectionRegistry.validate!(TalkToYourApp.required_connections)
|
|
41
|
+
validate_auth!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# At least one auth mechanism must be configured once the endpoint serves
|
|
45
|
+
# tools. With no plugins enabled (the default) there is nothing to protect,
|
|
46
|
+
# so the gem still boots cleanly with no configuration at all.
|
|
47
|
+
def self.validate_auth!
|
|
48
|
+
return if TalkToYourApp.configuration.enabled_plugins.empty?
|
|
49
|
+
return if TalkToYourApp.configuration.auth_configured?
|
|
50
|
+
|
|
51
|
+
raise ConfigurationError,
|
|
52
|
+
"talk_to_your_app: no authentication configured. Set `config.api_keys` or `config.basic_auth` " \
|
|
53
|
+
"in config/initializers/talk_to_your_app.rb (the MCP endpoint must not be exposed unauthenticated)."
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module TalkToYourApp
|
|
6
|
+
module Renderers
|
|
7
|
+
# Minimal HTML table renderer — no ActionView dependency. Every value is
|
|
8
|
+
# HTML-escaped via CGI.escape_html; the output is returned as a plain string
|
|
9
|
+
# for an MCP text content block and is never marked html_safe. NULL cells
|
|
10
|
+
# render as empty.
|
|
11
|
+
module HtmlTable
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def render(columns, rows)
|
|
15
|
+
head = "<thead><tr>#{columns.map { |c| "<th>#{esc(c)}</th>" }.join}</tr></thead>"
|
|
16
|
+
body = rows.map do |row|
|
|
17
|
+
"<tr>#{row.map { |cell| "<td>#{cell.nil? ? "" : esc(cell)}</td>" }.join}</tr>"
|
|
18
|
+
end.join
|
|
19
|
+
"<table>#{head}<tbody>#{body}</tbody></table>"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def esc(value)
|
|
23
|
+
CGI.escape_html(value.to_s)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require_relative "current"
|
|
6
|
+
require_relative "audit_logger"
|
|
7
|
+
|
|
8
|
+
module TalkToYourApp
|
|
9
|
+
# Base class for MCP tools. Authors subclass it, declare arguments with the
|
|
10
|
+
# class-level DSL, and implement `#call(args, ctx)`.
|
|
11
|
+
#
|
|
12
|
+
# class DbQueryTool < TalkToYourApp::Tool
|
|
13
|
+
# name "db.query"
|
|
14
|
+
# description "Run a read-only SQL query."
|
|
15
|
+
# connection :replica_readonly
|
|
16
|
+
# argument :sql, :string, required: true
|
|
17
|
+
# argument :format, :string, enum: %w[json text html], default: "json"
|
|
18
|
+
#
|
|
19
|
+
# def call(args, ctx)
|
|
20
|
+
# ctx.connection { |conn| ... }
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# A tool compiles down to an `MCP::Tool` subclass via `.to_mcp_tool`; the
|
|
25
|
+
# argument DSL produces the JSON Schema the SDK validates against.
|
|
26
|
+
class Tool
|
|
27
|
+
NOT_SET = Object.new
|
|
28
|
+
private_constant :NOT_SET
|
|
29
|
+
|
|
30
|
+
# The context handed to `#call`. Exposes the request principal and session,
|
|
31
|
+
# the audit logger, and role-switched database connections.
|
|
32
|
+
class Context
|
|
33
|
+
attr_reader :logger
|
|
34
|
+
|
|
35
|
+
def initialize(tool_class:, logger: nil)
|
|
36
|
+
@tool_class = tool_class
|
|
37
|
+
@logger = logger
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def principal
|
|
41
|
+
TalkToYourApp::Current.principal
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def session_id
|
|
45
|
+
TalkToYourApp::Current.session_id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Runs the block against a registry connection, switched to its declared
|
|
49
|
+
# role. With no name, uses the tool's declared `connection`.
|
|
50
|
+
def connection(name = nil, &block)
|
|
51
|
+
name ||= @tool_class.connection
|
|
52
|
+
if name.nil?
|
|
53
|
+
raise TalkToYourApp::ConfigurationError,
|
|
54
|
+
"tool #{@tool_class.tool_name.inspect} called ctx.connection without a name and declares no `connection`."
|
|
55
|
+
end
|
|
56
|
+
TalkToYourApp::ConnectionRegistry.with(name, &block)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
# --- DSL ---------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
# The MCP tool name (e.g. "db.query"). Overrides Class#name as a setter
|
|
64
|
+
# while preserving it as a reader before one is assigned.
|
|
65
|
+
def name(value = NOT_SET)
|
|
66
|
+
if value == NOT_SET
|
|
67
|
+
defined?(@tool_name) && @tool_name ? @tool_name : super()
|
|
68
|
+
else
|
|
69
|
+
@tool_name = value
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
alias_method :tool_name, :name
|
|
73
|
+
|
|
74
|
+
def description(value = NOT_SET)
|
|
75
|
+
value == NOT_SET ? @description : (@description = value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def connection(value = NOT_SET)
|
|
79
|
+
value == NOT_SET ? @connection : (@connection = value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def argument(arg_name, type, required: false, enum: nil, default: nil, description: nil, redact: false, minimum: nil, maximum: nil)
|
|
83
|
+
arguments[arg_name.to_sym] = {
|
|
84
|
+
type: type.to_s,
|
|
85
|
+
required: required,
|
|
86
|
+
enum: enum,
|
|
87
|
+
default: default,
|
|
88
|
+
description: description,
|
|
89
|
+
redact: redact,
|
|
90
|
+
minimum: minimum,
|
|
91
|
+
maximum: maximum,
|
|
92
|
+
}.compact
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def arguments
|
|
96
|
+
@arguments ||= {}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- Compilation -------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# JSON Schema (object) for the declared arguments.
|
|
102
|
+
def input_schema_hash
|
|
103
|
+
properties = arguments.each_with_object({}) do |(arg_name, opts), acc|
|
|
104
|
+
prop = { type: opts[:type] }
|
|
105
|
+
prop[:enum] = opts[:enum] if opts[:enum]
|
|
106
|
+
prop[:description] = opts[:description] if opts[:description]
|
|
107
|
+
prop[:minimum] = opts[:minimum] if opts[:minimum]
|
|
108
|
+
prop[:maximum] = opts[:maximum] if opts[:maximum]
|
|
109
|
+
acc[arg_name] = prop
|
|
110
|
+
end
|
|
111
|
+
required = arguments.select { |_, o| o[:required] }.keys.map(&:to_s)
|
|
112
|
+
schema = { properties: properties }
|
|
113
|
+
# Draft-04 (the SDK's metaschema) rejects an empty `required` array.
|
|
114
|
+
schema[:required] = required unless required.empty?
|
|
115
|
+
schema
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The shape used by tests and documentation.
|
|
119
|
+
def to_mcp_definition
|
|
120
|
+
{ name: tool_name, description: description, input_schema: input_schema_hash }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Builds the MCP::Tool subclass the server registers. Its class-level
|
|
124
|
+
# `call(**args, server_context:)` bridges to this tool's `#call(args, ctx)`.
|
|
125
|
+
# plugin_name and log_level are threaded through for the audit logger.
|
|
126
|
+
def to_mcp_tool(plugin_name: nil, log_level: nil)
|
|
127
|
+
if tool_name.nil? || tool_name.to_s.empty?
|
|
128
|
+
raise TalkToYourApp::ConfigurationError,
|
|
129
|
+
"#{inspect} has no MCP tool name — call `name \"your.tool\"` in the tool class."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
ttya_tool = self
|
|
133
|
+
MCP::Tool.define(
|
|
134
|
+
name: ttya_tool.tool_name,
|
|
135
|
+
description: ttya_tool.description,
|
|
136
|
+
input_schema: ttya_tool.input_schema_hash,
|
|
137
|
+
) do |server_context: nil, **args|
|
|
138
|
+
ttya_tool.dispatch(args, plugin_name: plugin_name, log_level: log_level)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Invocation entry point, wrapped by the audit logger: one log line per
|
|
143
|
+
# call. Applies argument defaults, runs the tool, and normalizes the return
|
|
144
|
+
# into an MCP::Tool::Response.
|
|
145
|
+
def dispatch(args, plugin_name: nil, log_level: nil)
|
|
146
|
+
AuditLogger.around(tool_class: self, plugin_name: plugin_name, log_level: log_level, params: args) do
|
|
147
|
+
if TalkToYourApp.configuration.authorized?(TalkToYourApp::Current.principal, tool_name)
|
|
148
|
+
invoke(args)
|
|
149
|
+
else
|
|
150
|
+
MCP::Tool::Response.new(
|
|
151
|
+
[{ type: "text", text: "Not authorized: principal may not call #{tool_name}." }],
|
|
152
|
+
error: true,
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def invoke(args)
|
|
159
|
+
merged = default_arguments.merge(args)
|
|
160
|
+
ctx = Context.new(tool_class: self, logger: TalkToYourApp.configuration.logger)
|
|
161
|
+
normalize_response(new.call(merged, ctx))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Static metadata, computed once per tool class.
|
|
165
|
+
def default_arguments
|
|
166
|
+
@default_arguments ||= arguments.each_with_object({}) do |(arg_name, opts), acc|
|
|
167
|
+
acc[arg_name] = opts[:default] unless opts[:default].nil?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize_response(result)
|
|
172
|
+
case result
|
|
173
|
+
when MCP::Tool::Response
|
|
174
|
+
result
|
|
175
|
+
when String
|
|
176
|
+
MCP::Tool::Response.new([{ type: "text", text: result }])
|
|
177
|
+
else
|
|
178
|
+
MCP::Tool::Response.new([{ type: "text", text: result.to_json }])
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Tool authors override this.
|
|
184
|
+
def call(_args, _ctx)
|
|
185
|
+
raise NotImplementedError, "#{self.class}#call must be implemented"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# --- Response helpers available to subclasses ---------------------------
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def text(string)
|
|
193
|
+
MCP::Tool::Response.new([{ type: "text", text: string.to_s }])
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def json(object)
|
|
197
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(object) }])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def error(message)
|
|
201
|
+
MCP::Tool::Response.new([{ type: "text", text: message.to_s }], error: true)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../auth/middleware"
|
|
5
|
+
|
|
6
|
+
module TalkToYourApp
|
|
7
|
+
module Transport
|
|
8
|
+
# Builds the Rack application the host app mounts. It assembles an
|
|
9
|
+
# MCP::Server from the enabled plugins' tools, wraps it in the SDK's
|
|
10
|
+
# Streamable HTTP transport, and fronts the whole thing with the auth
|
|
11
|
+
# middleware. The session layer lives entirely in the SDK transport, so a
|
|
12
|
+
# future stateless flip (2026-07-28 RC) is a one-line change here.
|
|
13
|
+
module RailsMount
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def build
|
|
17
|
+
config = TalkToYourApp.configuration
|
|
18
|
+
server = MCP::Server.new(
|
|
19
|
+
name: config.server_name,
|
|
20
|
+
title: config.server_title,
|
|
21
|
+
version: config.server_version,
|
|
22
|
+
description: config.server_description,
|
|
23
|
+
instructions: config.instructions,
|
|
24
|
+
tools: collect_tools,
|
|
25
|
+
)
|
|
26
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
27
|
+
server,
|
|
28
|
+
enable_json_response: true,
|
|
29
|
+
)
|
|
30
|
+
Auth::Middleware.new(transport)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Compiles every enabled plugin's tools into MCP::Tool subclasses, each
|
|
34
|
+
# audit-wrapped with its plugin name and (optional) per-plugin log level.
|
|
35
|
+
def collect_tools
|
|
36
|
+
TalkToYourApp.enabled_plugins.flat_map do |name, plugin_class, _opts|
|
|
37
|
+
next [] unless plugin_class
|
|
38
|
+
|
|
39
|
+
plugin_class.tools.map do |tool_class|
|
|
40
|
+
tool_class.to_mcp_tool(plugin_name: name, log_level: plugin_class.log_level)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "talk_to_your_app/version"
|
|
4
|
+
require_relative "talk_to_your_app/configuration"
|
|
5
|
+
require_relative "talk_to_your_app/connection_registry"
|
|
6
|
+
require_relative "talk_to_your_app/current"
|
|
7
|
+
require_relative "talk_to_your_app/audit_logger"
|
|
8
|
+
require_relative "talk_to_your_app/tool"
|
|
9
|
+
require_relative "talk_to_your_app/plugin"
|
|
10
|
+
require_relative "talk_to_your_app/plugin_registry"
|
|
11
|
+
require_relative "talk_to_your_app/transport/rails_mount"
|
|
12
|
+
|
|
13
|
+
# Rails-native MCP server. See README for the configuration reference.
|
|
14
|
+
module TalkToYourApp
|
|
15
|
+
# Convention directory for host-app extensions: custom_tools/ holds CustomTool
|
|
16
|
+
# subclasses, health/ holds health-check files. The railtie tells Zeitwerk to
|
|
17
|
+
# ignore this tree (health files define no constant; tools we require here), so
|
|
18
|
+
# the gem loads its files itself via require_app_dir.
|
|
19
|
+
APP_DIR = "app/talk_to_your_app"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Yields the configuration singleton for the host app's initializer.
|
|
23
|
+
#
|
|
24
|
+
# TalkToYourApp.configure do |config|
|
|
25
|
+
# config.mount_at = "/mcp"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# Calling this more than once merges into the same object rather than
|
|
29
|
+
# replacing it.
|
|
30
|
+
def configure
|
|
31
|
+
yield(configuration) if block_given?
|
|
32
|
+
configuration
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def configuration
|
|
36
|
+
@configuration ||= Configuration.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Test seam: drop all configuration back to defaults.
|
|
40
|
+
def reset_configuration!
|
|
41
|
+
@configuration = Configuration.new
|
|
42
|
+
@rack_app = nil
|
|
43
|
+
ConnectionRegistry.reset!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The Rack application to mount in the host app's routes:
|
|
47
|
+
#
|
|
48
|
+
# mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
|
|
49
|
+
#
|
|
50
|
+
# Built once from the enabled plugins. reset_configuration! clears it.
|
|
51
|
+
def rack_app
|
|
52
|
+
@rack_app ||= Transport::RailsMount.build
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Registers a plugin class under a name so it can be enabled in the
|
|
56
|
+
# initializer. Bundled plugins register themselves on load; plugin authors
|
|
57
|
+
# call this from their own code.
|
|
58
|
+
def register_plugin(name, plugin_class)
|
|
59
|
+
PluginRegistry.register(name, plugin_class)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The enabled plugins as [name, plugin_class, options] triples, in the order
|
|
63
|
+
# they were enabled. plugin_class is nil if the name was never registered
|
|
64
|
+
# (validation surfaces that at boot).
|
|
65
|
+
def enabled_plugins
|
|
66
|
+
configuration.enabled_plugins.map do |name, opts|
|
|
67
|
+
[name, PluginRegistry[name], opts]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Connections required by the enabled plugins and their tools, as
|
|
72
|
+
# [connection_name, requester_label] pairs, for ConnectionRegistry.validate!.
|
|
73
|
+
def required_connections
|
|
74
|
+
requirements = []
|
|
75
|
+
enabled_plugins.each do |name, plugin_class, _opts|
|
|
76
|
+
next unless plugin_class
|
|
77
|
+
|
|
78
|
+
Array(plugin_class.required_connections).each do |conn_name|
|
|
79
|
+
requirements << [conn_name, "Plugin #{name.inspect}"]
|
|
80
|
+
end
|
|
81
|
+
plugin_class.tools.each do |tool_class|
|
|
82
|
+
requirements << [tool_class.connection, "Tool #{tool_class.tool_name.inspect}"] if tool_class.connection
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
requirements
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Requires every .rb under app/talk_to_your_app/<subdir> so the files'
|
|
89
|
+
# side effects run (CustomTool subclasses register; health checks call
|
|
90
|
+
# Health.register). require is idempotent, so calling this repeatedly loads
|
|
91
|
+
# each file once per process (edits need a restart). No-op when the directory
|
|
92
|
+
# or Rails is absent. Each file is loaded in isolation: one file that fails
|
|
93
|
+
# to load is logged and skipped so it can't suppress the others. ScriptError
|
|
94
|
+
# is rescued alongside StandardError so a syntax/load error is reported the
|
|
95
|
+
# same way as a runtime error rather than escaping uncaught.
|
|
96
|
+
def require_app_dir(subdir)
|
|
97
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
98
|
+
|
|
99
|
+
dir = ::Rails.root.join(APP_DIR, subdir)
|
|
100
|
+
return unless File.directory?(dir)
|
|
101
|
+
|
|
102
|
+
Dir[File.join(dir, "**/*.rb")].sort.each do |file|
|
|
103
|
+
require file
|
|
104
|
+
rescue StandardError, ScriptError => e
|
|
105
|
+
message = "talk_to_your_app: failed to load #{file}: #{e.class}: #{e.message}"
|
|
106
|
+
logger = configuration.logger
|
|
107
|
+
logger ? logger.error(message) : $stderr.puts(message)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
require_relative "talk_to_your_app/railtie" if defined?(Rails::Railtie)
|
|
114
|
+
|
|
115
|
+
# Bundled plugins self-register on load (after register_plugin is defined). They
|
|
116
|
+
# are off by default; the operator enables them in the initializer. Soft-dep
|
|
117
|
+
# constants are referenced only inside tool calls, so loading these files never
|
|
118
|
+
# requires the backing gem.
|
|
119
|
+
require_relative "talk_to_your_app/plugins/db/plugin"
|
|
120
|
+
require_relative "talk_to_your_app/plugins/jobs/plugin"
|
|
121
|
+
require_relative "talk_to_your_app/plugins/flipper/plugin"
|
|
122
|
+
require_relative "talk_to_your_app/plugins/health/plugin"
|
|
123
|
+
require_relative "talk_to_your_app/plugins/rake/plugin"
|
|
124
|
+
require_relative "talk_to_your_app/plugins/custom_tools/plugin"
|
metadata
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: talk_to_your_app
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0.pre.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Igor
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: mcp
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.18'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.18'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activerecord
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: railties
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '7.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '7.1'
|
|
54
|
+
description: |
|
|
55
|
+
A thin Rails layer over the official MCP Ruby SDK. Mounts a Streamable HTTP
|
|
56
|
+
MCP endpoint, gates access behind API-key or HTTP Basic auth, enforces
|
|
57
|
+
fail-closed per-tool database roles, and ships six bundled plugins
|
|
58
|
+
(DB, Jobs, Flipper, Health, Rake, Custom Tools) plus a Ruby DSL for
|
|
59
|
+
writing your own.
|
|
60
|
+
email:
|
|
61
|
+
- igor@everai.ai
|
|
62
|
+
executables: []
|
|
63
|
+
extensions: []
|
|
64
|
+
extra_rdoc_files: []
|
|
65
|
+
files:
|
|
66
|
+
- LICENSE
|
|
67
|
+
- README.md
|
|
68
|
+
- lib/generators/talk_to_your_app/custom_tool/custom_tool_generator.rb
|
|
69
|
+
- lib/generators/talk_to_your_app/custom_tool/templates/tool.rb.tt
|
|
70
|
+
- lib/generators/talk_to_your_app/health_check/health_check_generator.rb
|
|
71
|
+
- lib/generators/talk_to_your_app/health_check/templates/check.rb.tt
|
|
72
|
+
- lib/generators/talk_to_your_app/install/install_generator.rb
|
|
73
|
+
- lib/generators/talk_to_your_app/install/templates/initializer.rb.tt
|
|
74
|
+
- lib/talk_to_your_app.rb
|
|
75
|
+
- lib/talk_to_your_app/audit_logger.rb
|
|
76
|
+
- lib/talk_to_your_app/auth/api_key.rb
|
|
77
|
+
- lib/talk_to_your_app/auth/basic.rb
|
|
78
|
+
- lib/talk_to_your_app/auth/middleware.rb
|
|
79
|
+
- lib/talk_to_your_app/configuration.rb
|
|
80
|
+
- lib/talk_to_your_app/connection_registry.rb
|
|
81
|
+
- lib/talk_to_your_app/current.rb
|
|
82
|
+
- lib/talk_to_your_app/custom_tool.rb
|
|
83
|
+
- lib/talk_to_your_app/plugin.rb
|
|
84
|
+
- lib/talk_to_your_app/plugin_registry.rb
|
|
85
|
+
- lib/talk_to_your_app/plugins/custom_tools/plugin.rb
|
|
86
|
+
- lib/talk_to_your_app/plugins/db/plugin.rb
|
|
87
|
+
- lib/talk_to_your_app/plugins/db/tools/query.rb
|
|
88
|
+
- lib/talk_to_your_app/plugins/db/tools/schema.rb
|
|
89
|
+
- lib/talk_to_your_app/plugins/db/tools/tables.rb
|
|
90
|
+
- lib/talk_to_your_app/plugins/flipper/plugin.rb
|
|
91
|
+
- lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb
|
|
92
|
+
- lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb
|
|
93
|
+
- lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb
|
|
94
|
+
- lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb
|
|
95
|
+
- lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb
|
|
96
|
+
- lib/talk_to_your_app/plugins/health/plugin.rb
|
|
97
|
+
- lib/talk_to_your_app/plugins/health/registry.rb
|
|
98
|
+
- lib/talk_to_your_app/plugins/health/tools/list_checks.rb
|
|
99
|
+
- lib/talk_to_your_app/plugins/health/tools/run_check.rb
|
|
100
|
+
- lib/talk_to_your_app/plugins/jobs/adapters/sidekiq.rb
|
|
101
|
+
- lib/talk_to_your_app/plugins/jobs/adapters/solid_queue.rb
|
|
102
|
+
- lib/talk_to_your_app/plugins/jobs/interface.rb
|
|
103
|
+
- lib/talk_to_your_app/plugins/jobs/plugin.rb
|
|
104
|
+
- lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb
|
|
105
|
+
- lib/talk_to_your_app/plugins/jobs/tools/health.rb
|
|
106
|
+
- lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb
|
|
107
|
+
- lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb
|
|
108
|
+
- lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb
|
|
109
|
+
- lib/talk_to_your_app/plugins/rake/plugin.rb
|
|
110
|
+
- lib/talk_to_your_app/plugins/rake/tools/run.rb
|
|
111
|
+
- lib/talk_to_your_app/railtie.rb
|
|
112
|
+
- lib/talk_to_your_app/renderers/html_table.rb
|
|
113
|
+
- lib/talk_to_your_app/tool.rb
|
|
114
|
+
- lib/talk_to_your_app/transport/rails_mount.rb
|
|
115
|
+
- lib/talk_to_your_app/version.rb
|
|
116
|
+
homepage: https://github.com/everai/talk_to_your_app
|
|
117
|
+
licenses:
|
|
118
|
+
- MIT
|
|
119
|
+
metadata:
|
|
120
|
+
homepage_uri: https://github.com/everai/talk_to_your_app
|
|
121
|
+
source_code_uri: https://github.com/everai/talk_to_your_app
|
|
122
|
+
rubygems_mfa_required: 'true'
|
|
123
|
+
rdoc_options: []
|
|
124
|
+
require_paths:
|
|
125
|
+
- lib
|
|
126
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '3.2'
|
|
131
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
132
|
+
requirements:
|
|
133
|
+
- - ">="
|
|
134
|
+
- !ruby/object:Gem::Version
|
|
135
|
+
version: '0'
|
|
136
|
+
requirements: []
|
|
137
|
+
rubygems_version: 3.7.2
|
|
138
|
+
specification_version: 4
|
|
139
|
+
summary: 'Rails-native MCP server: ask your running app real questions over MCP.'
|
|
140
|
+
test_files: []
|