rails-ai-context 5.32.2 → 5.34.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 +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- metadata +14 -1
|
@@ -9,6 +9,22 @@ module RailsAiContext
|
|
|
9
9
|
class Server
|
|
10
10
|
attr_reader :app, :transport_type
|
|
11
11
|
|
|
12
|
+
STOP_SIGNALS = %w[TERM INT].freeze
|
|
13
|
+
|
|
14
|
+
LOOPBACK = %w[127.0.0.1 ::1 localhost].freeze
|
|
15
|
+
|
|
16
|
+
# Seconds a stopping server gives requests still in flight. Puma's
|
|
17
|
+
# default is to wait for them forever, so one request that never
|
|
18
|
+
# finished - a tool stuck on a lock, a client that stopped reading - kept
|
|
19
|
+
# a stopped server running until SIGKILL. Puma then gives the request a
|
|
20
|
+
# further grace period of its own before it kills the thread, so a stop
|
|
21
|
+
# ends within about eight seconds.
|
|
22
|
+
SHUTDOWN_TIMEOUT = 3
|
|
23
|
+
|
|
24
|
+
# Past this many seconds after a stop signal the process exits whatever
|
|
25
|
+
# is still running: the stop above has long had its chance by then.
|
|
26
|
+
STOP_DEADLINE = 15
|
|
27
|
+
|
|
12
28
|
# All built-in tools, auto-discovered from Tools::BaseTool subclasses.
|
|
13
29
|
# Kept as a class method (not a constant) so auto-registration works.
|
|
14
30
|
# Legacy constant accessor preserved for backwards compatibility.
|
|
@@ -84,62 +100,47 @@ module RailsAiContext
|
|
|
84
100
|
end
|
|
85
101
|
end
|
|
86
102
|
|
|
87
|
-
#
|
|
88
|
-
def
|
|
89
|
-
config
|
|
90
|
-
|
|
91
|
-
validated_custom_tools = self.class.resolve_custom_tools(config)
|
|
92
|
-
|
|
93
|
-
mcp_config = MCP::Configuration.new(
|
|
94
|
-
# Anything that still escapes a tool (schema validation bugs, SDK-level
|
|
95
|
-
# failures) gets a stderr backtrace instead of vanishing into a bare
|
|
96
|
-
# JSON-RPC internal error. Routine protocol-level errors (unknown
|
|
97
|
-
# tool, invalid params) are expected traffic, not bugs - the mcp gem
|
|
98
|
-
# already turns them into a proper JSON-RPC error response, so here
|
|
99
|
-
# they get one quiet line instead of a scary 10-line backtrace.
|
|
100
|
-
exception_reporter: lambda { |exception, _server_context|
|
|
101
|
-
if exception.is_a?(MCP::Server::RequestHandlerError) && exception.error_type != :internal_error
|
|
102
|
-
$stderr.puts "[rails-ai-context] request error (#{exception.error_type}): #{exception.message}"
|
|
103
|
-
else
|
|
104
|
-
$stderr.puts "[rails-ai-context] unhandled exception: #{exception.class}: #{exception.message}"
|
|
105
|
-
Array(exception.backtrace).first(10).each { |line| $stderr.puts " #{line}" }
|
|
106
|
-
end
|
|
107
|
-
},
|
|
108
|
-
instrumentation_callback: Instrumentation.callback
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
server = MCP::Server.new(
|
|
112
|
-
name: config.server_name,
|
|
113
|
-
version: config.server_version,
|
|
114
|
-
instructions: "Ground truth engine for Rails apps. Live Prism AST introspection. Zero stale data.",
|
|
115
|
-
tools: merge_tools(active_tools(config), validated_custom_tools),
|
|
116
|
-
resource_templates: Resources.resource_templates,
|
|
117
|
-
configuration: mcp_config
|
|
118
|
-
)
|
|
103
|
+
# The built-in tools skip_tools turned off.
|
|
104
|
+
def self.skipped_tools(config = RailsAiContext.configuration)
|
|
105
|
+
config.skip_tools & builtin_tools.map(&:tool_name)
|
|
106
|
+
end
|
|
119
107
|
|
|
120
|
-
|
|
108
|
+
# One answer, on every surface, for a call naming a tool skip_tools
|
|
109
|
+
# turned off: it is out of every list, and "Unknown tool" sent the
|
|
110
|
+
# reader looking for a tool the gem has.
|
|
111
|
+
def self.skipped_tool_message(tool_name)
|
|
112
|
+
"#{tool_name} is turned off in this app: skip_tools lists it " \
|
|
113
|
+
"(.rails-ai-context.yml or config/initializers/rails_ai_context.rb). Take it out of skip_tools to use it."
|
|
114
|
+
end
|
|
121
115
|
|
|
122
|
-
|
|
116
|
+
# The SDK's "Tool not found" named only the guess. The nearest tools
|
|
117
|
+
# follow it, or every tool when none is near.
|
|
118
|
+
def self.unknown_tool_message(tool_name, names)
|
|
119
|
+
near = Tools::BaseTool.find_closest_matches(tool_name.to_s, names) - [ tool_name.to_s ]
|
|
120
|
+
head = "Tool not found: #{Tools::BaseTool.echo_input(tool_name)}."
|
|
121
|
+
near.any? ? "#{head} Did you mean #{near.first(3).join(', ')}?" : "#{head} The tools are: #{names.sort.join(', ')}."
|
|
123
122
|
end
|
|
124
123
|
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
# Where the SDK carries a code and data on a handler's error, the
|
|
125
|
+
# message stays the error's own (not "Invalid params") and the data
|
|
126
|
+
# lists every tool, as a resource read lists the names it has.
|
|
127
|
+
def self.unknown_tool_data(names)
|
|
128
|
+
parameters = MCP::Server::RequestHandlerError.instance_method(:initialize).parameters
|
|
129
|
+
return {} unless parameters.any? { |_, name| name == :error_data }
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
when :stdio
|
|
131
|
-
start_stdio(server)
|
|
132
|
-
when :http, :streamable_http
|
|
133
|
-
start_http(server)
|
|
134
|
-
else
|
|
135
|
-
raise ConfigurationError, "Unknown transport: #{transport_type}. Use :stdio or :http"
|
|
136
|
-
end
|
|
131
|
+
{ error_code: -32602, error_data: { available: names.sort } }
|
|
137
132
|
end
|
|
138
133
|
|
|
139
|
-
|
|
134
|
+
# What tools/list answers with: the built-ins skip_tools leaves, then
|
|
135
|
+
# each custom tool whose name no tool before it claims. The generated
|
|
136
|
+
# context files count and list this set, so they name no tool the
|
|
137
|
+
# server lacks and leave out none it serves.
|
|
138
|
+
def self.exposed_tools(config = RailsAiContext.configuration)
|
|
139
|
+
merge_tools(active_tools(config), resolve_custom_tools(config), warn: false)
|
|
140
|
+
end
|
|
140
141
|
|
|
141
|
-
def active_tools(config)
|
|
142
|
-
tools =
|
|
142
|
+
def self.active_tools(config)
|
|
143
|
+
tools = builtin_tools
|
|
143
144
|
skip = config.skip_tools
|
|
144
145
|
return tools if skip.empty?
|
|
145
146
|
|
|
@@ -154,7 +155,7 @@ module RailsAiContext
|
|
|
154
155
|
# `inherited` and enrols it in the registry active_tools reads.
|
|
155
156
|
# - Two different classes claiming one name. Deliberate replacement is
|
|
156
157
|
# spelled with skip_tools; without it, keep the built-in and say so.
|
|
157
|
-
def merge_tools(builtin, custom)
|
|
158
|
+
def self.merge_tools(builtin, custom, warn: true)
|
|
158
159
|
merged = (builtin + custom).uniq
|
|
159
160
|
builtin_names = builtin.to_set { |t| tool_label(t) }
|
|
160
161
|
|
|
@@ -169,34 +170,159 @@ module RailsAiContext
|
|
|
169
170
|
else
|
|
170
171
|
"keeping the first. Give one of them a different tool_name."
|
|
171
172
|
end
|
|
172
|
-
$stderr.puts "[rails-ai-context] WARNING: #{tools.size} tools claim the name #{name.inspect}; #{advice}"
|
|
173
|
+
$stderr.puts "[rails-ai-context] WARNING: #{tools.size} tools claim the name #{name.inspect}; #{advice}" if warn
|
|
173
174
|
[ tools.first ]
|
|
174
175
|
end
|
|
175
176
|
end
|
|
176
177
|
|
|
177
178
|
# MCP::Tool subclasses answer tool_name; anything else falls back to the
|
|
178
179
|
# class name, which is what the SDK would key on anyway.
|
|
179
|
-
def tool_label(tool)
|
|
180
|
+
def self.tool_label(tool)
|
|
180
181
|
tool.respond_to?(:tool_name) ? tool.tool_name : tool.name
|
|
181
182
|
end
|
|
182
183
|
|
|
184
|
+
# The name the server gives the client: config.server_name, and when
|
|
185
|
+
# that is the default, RAILS_AI_CONTEXT_SERVER_NAME in its place. A
|
|
186
|
+
# workspace's entries set the variable so each server announces its app
|
|
187
|
+
# first, since VS Code names every tool after this name and keeps 13
|
|
188
|
+
# characters of it; a name the app configured itself still wins.
|
|
189
|
+
def self.announced_name(config = RailsAiContext.configuration)
|
|
190
|
+
name = ENV[McpConfigGenerator::SERVER_NAME_ENV].to_s.strip
|
|
191
|
+
return config.server_name if name.empty? || config.server_name != McpConfigGenerator::SERVER_NAME
|
|
192
|
+
|
|
193
|
+
name
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# What the client is told about freshness. An app that cannot reload
|
|
197
|
+
# keeps what reflection read at boot, and its answers say so once app
|
|
198
|
+
# code changed (BaseTool.stale_code_note).
|
|
199
|
+
def self.instructions
|
|
200
|
+
intro = "Ground truth engine for Rails apps. Live Prism AST introspection."
|
|
201
|
+
return "#{intro} Answers follow edits to the app's files." if CodeReloader.reloadable? || RailsAiContext.static_tier?
|
|
202
|
+
|
|
203
|
+
"#{intro} Answers follow edits to the app's files, except what reflection reads, such as associations: " \
|
|
204
|
+
"RAILS_ENV=#{RailsAiContext.environment_name} does not reload code, so that stays as of boot, and every " \
|
|
205
|
+
"answer says when app code changed since."
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Anything that still escapes a tool (schema validation bugs, SDK-level
|
|
209
|
+
# failures) is named on stderr instead of vanishing into a bare JSON-RPC
|
|
210
|
+
# internal error, in one line; the backtrace is DEBUG's, as it is for a
|
|
211
|
+
# boot failure. Routine protocol-level errors (unknown tool, invalid
|
|
212
|
+
# params) are expected traffic, not bugs - the mcp gem already turns them
|
|
213
|
+
# into a proper JSON-RPC error response, so here they get one quiet line.
|
|
214
|
+
EXCEPTION_REPORTER = lambda { |exception, _server_context|
|
|
215
|
+
if exception.is_a?(MCP::Server::RequestHandlerError) && exception.error_type != :internal_error
|
|
216
|
+
$stderr.puts "[rails-ai-context] request error (#{exception.error_type}): #{exception.message}"
|
|
217
|
+
elsif ENV["DEBUG"]
|
|
218
|
+
$stderr.puts "[rails-ai-context] unhandled exception: #{exception.class}: #{exception.message}"
|
|
219
|
+
Array(exception.backtrace).first(10).each { |line| $stderr.puts " #{line}" }
|
|
220
|
+
else
|
|
221
|
+
$stderr.puts "[rails-ai-context] unhandled exception: #{exception.class}: " \
|
|
222
|
+
"#{exception.message.to_s.lines.first&.strip} (DEBUG=1 prints the backtrace)"
|
|
223
|
+
end
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Build and return the configured MCP::Server instance
|
|
227
|
+
def build
|
|
228
|
+
config = RailsAiContext.configuration
|
|
229
|
+
|
|
230
|
+
validated_custom_tools = self.class.resolve_custom_tools(config)
|
|
231
|
+
|
|
232
|
+
mcp_config = MCP::Configuration.new(
|
|
233
|
+
exception_reporter: EXCEPTION_REPORTER,
|
|
234
|
+
instrumentation_callback: Instrumentation.callback
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
server = SdkServer.new(
|
|
238
|
+
skipped_tools: self.class.skipped_tools(config),
|
|
239
|
+
name: self.class.announced_name(config),
|
|
240
|
+
version: config.server_version,
|
|
241
|
+
instructions: self.class.instructions,
|
|
242
|
+
tools: self.class.merge_tools(self.class.active_tools(config), validated_custom_tools),
|
|
243
|
+
resource_templates: Resources.resource_templates,
|
|
244
|
+
configuration: mcp_config
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
Resources.register(server)
|
|
248
|
+
|
|
249
|
+
server
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Start the MCP server with the configured transport
|
|
253
|
+
def start
|
|
254
|
+
server = build
|
|
255
|
+
report_transport_errors
|
|
256
|
+
|
|
257
|
+
case transport_type
|
|
258
|
+
when :stdio
|
|
259
|
+
start_stdio(server)
|
|
260
|
+
when :http, :streamable_http
|
|
261
|
+
start_http(server)
|
|
262
|
+
else
|
|
263
|
+
raise ConfigurationError, "Unknown transport: #{transport_type}. Use :stdio or :http"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
# The SDK's transports report through MCP.configuration's reporter, not
|
|
270
|
+
# the server's, so a stdio frame past the size limit ended the server
|
|
271
|
+
# with nothing on stderr. This process serves only this server, so the
|
|
272
|
+
# process-wide reporter is this one too, unless the app set its own.
|
|
273
|
+
def report_transport_errors
|
|
274
|
+
configuration = MCP.configuration
|
|
275
|
+
return unless configuration.respond_to?(:exception_reporter=)
|
|
276
|
+
return if configuration.respond_to?(:exception_reporter?) && configuration.exception_reporter?
|
|
277
|
+
|
|
278
|
+
configuration.exception_reporter = EXCEPTION_REPORTER
|
|
279
|
+
end
|
|
280
|
+
|
|
183
281
|
# Read the list off the server rather than rebuilding it. Recomputing it
|
|
184
282
|
# from the registry drops any custom tool that is not a BaseTool, so the
|
|
185
283
|
# banner announced a different set than the server answered with.
|
|
186
284
|
def tool_banner(server)
|
|
187
|
-
names = server.tools.values.map { |t| tool_label(t) }.sort
|
|
285
|
+
names = server.tools.values.map { |t| self.class.tool_label(t) }.sort
|
|
188
286
|
"[rails-ai-context] Tools (#{names.size}): #{names.join(', ')}"
|
|
189
287
|
end
|
|
190
288
|
|
|
289
|
+
# The SDK's server, told which tools skip_tools turned off. They are out
|
|
290
|
+
# of tools/list, but a client can still name one - a context file written
|
|
291
|
+
# before the skip, a model's guess - and the SDK answered "Tool not
|
|
292
|
+
# found", a protocol error most clients never show the model.
|
|
293
|
+
class SdkServer < MCP::Server
|
|
294
|
+
def initialize(skipped_tools: [], **options)
|
|
295
|
+
@skipped_tools = skipped_tools
|
|
296
|
+
super(**options)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
private
|
|
300
|
+
|
|
301
|
+
def call_tool(request, ...)
|
|
302
|
+
name = request[:name]
|
|
303
|
+
return super if tools.key?(name)
|
|
304
|
+
return MCP::Tool::Response.new([ { type: "text", text: Server.skipped_tool_message(name) } ], error: true).to_h if @skipped_tools.include?(name)
|
|
305
|
+
|
|
306
|
+
add_instrumentation_data(tool_name: name, error: :tool_not_found) if respond_to?(:add_instrumentation_data, true)
|
|
307
|
+
raise MCP::Server::RequestHandlerError.new(Server.unknown_tool_message(name, tools.keys), request,
|
|
308
|
+
error_type: :invalid_params, **Server.unknown_tool_data(tools.keys))
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
191
312
|
# Writes to the saved channel, since the session points $stdout and fd 1
|
|
192
313
|
# at stderr so nothing a tool prints reaches the JSON-RPC stream.
|
|
193
314
|
class StdioChannelTransport < MCP::Server::Transports::StdioTransport
|
|
315
|
+
# The frame past the SDK's size limit that ended the connection, if one did.
|
|
316
|
+
attr_reader :frame_error
|
|
317
|
+
|
|
194
318
|
# The SDK sets UTF-8 on $stdout, which is $stderr here; it goes on the channel instead.
|
|
195
319
|
def initialize(server, channel)
|
|
196
320
|
stderr_encoding = [ $stdout.external_encoding, $stdout.internal_encoding ]
|
|
197
321
|
super(server)
|
|
198
322
|
$stdout.set_encoding(*stderr_encoding)
|
|
199
323
|
@channel = channel
|
|
324
|
+
# A frame ends in a newline alone; Windows writes one as CRLF in text mode.
|
|
325
|
+
@channel.binmode
|
|
200
326
|
@channel.set_encoding("UTF-8")
|
|
201
327
|
end
|
|
202
328
|
|
|
@@ -204,6 +330,20 @@ module RailsAiContext
|
|
|
204
330
|
@channel.puts(message.is_a?(String) ? message : JSON.generate(message))
|
|
205
331
|
@channel.flush
|
|
206
332
|
end
|
|
333
|
+
|
|
334
|
+
private
|
|
335
|
+
|
|
336
|
+
# The SDK ends the connection when a frame passes its size limit (4 MiB)
|
|
337
|
+
# without a newline, and the server exited 0 with the client's request
|
|
338
|
+
# unanswered. The client gets a JSON-RPC error first, and the failure
|
|
339
|
+
# is kept for start_stdio to end on.
|
|
340
|
+
def read_line(io)
|
|
341
|
+
super
|
|
342
|
+
rescue MCP::Server::RequestHandlerError => e
|
|
343
|
+
@frame_error = e
|
|
344
|
+
send_response({ jsonrpc: "2.0", id: nil, error: { code: McpEdge::INVALID_REQUEST, message: "Invalid Request: #{e.message}" } })
|
|
345
|
+
raise
|
|
346
|
+
end
|
|
207
347
|
end
|
|
208
348
|
|
|
209
349
|
def start_stdio(server)
|
|
@@ -214,6 +354,7 @@ module RailsAiContext
|
|
|
214
354
|
RailsAiContext.stdio_open = true
|
|
215
355
|
maybe_start_live_reload(server)
|
|
216
356
|
transport.open
|
|
357
|
+
raise RailsAiContext::Error, "MCP stdio connection closed: #{transport.frame_error.message}" if transport.frame_error
|
|
217
358
|
end
|
|
218
359
|
ensure
|
|
219
360
|
RailsAiContext.stdio_open = false
|
|
@@ -226,18 +367,102 @@ module RailsAiContext
|
|
|
226
367
|
# Build a minimal Rack app that delegates to the MCP transport
|
|
227
368
|
rack_app = build_rack_app(transport)
|
|
228
369
|
|
|
229
|
-
|
|
230
|
-
unless loopback.include?(config.http_bind)
|
|
231
|
-
$stderr.puts "[rails-ai-context] WARNING: MCP HTTP transport binding to #{config.http_bind} - " \
|
|
232
|
-
"this exposes all tools to the network without authentication. " \
|
|
233
|
-
"Use 127.0.0.1 (default) unless you have external auth in place."
|
|
234
|
-
end
|
|
370
|
+
$stderr.puts bind_warning(config.http_bind) unless LOOPBACK.include?(config.http_bind)
|
|
235
371
|
$stderr.puts "[rails-ai-context] MCP server starting on #{config.http_bind}:#{config.http_port}#{config.http_path}"
|
|
236
372
|
$stderr.puts tool_banner(server)
|
|
237
373
|
maybe_start_live_reload(server)
|
|
238
374
|
|
|
239
375
|
handler = default_rack_handler
|
|
240
|
-
handler.run(rack_app, **rack_handler_options(handler, config))
|
|
376
|
+
handler.run(rack_app, **rack_handler_options(handler, config)) do |launcher|
|
|
377
|
+
stop_outside_signal_handler(launcher) if puma_handler?(handler)
|
|
378
|
+
end
|
|
379
|
+
rescue SignalException => e
|
|
380
|
+
# Puma stops gracefully on SIGTERM and then raises it, so a stop that
|
|
381
|
+
# had already finished reached rake as "bin/rails aborted!
|
|
382
|
+
# SignalException: SIGTERM". A stop signal ends the server, quietly.
|
|
383
|
+
raise unless STOP_SIGNALS.include?(Signal.signame(e.signo))
|
|
384
|
+
ensure
|
|
385
|
+
stop_http(transport)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Puma runs a SIGTERM stop inside the signal handler: it writes to its
|
|
389
|
+
# wake-up pipe there and then joins the server thread, which closes that
|
|
390
|
+
# pipe on its way out. When the close catches the handler's write still
|
|
391
|
+
# registered on the pipe, Ruby makes the closer wait for the writer to
|
|
392
|
+
# let go, which needs a mutex code in a signal handler may not take, so
|
|
393
|
+
# neither thread moved again: the server printed "Gracefully stopping"
|
|
394
|
+
# and hung until SIGKILL, and a second signal did nothing because signal
|
|
395
|
+
# handlers do not nest. Here a stop signal only queues the stop, and an
|
|
396
|
+
# ordinary thread asks Puma for it - the non-blocking stop its own
|
|
397
|
+
# SIGINT handler uses - then ends the process if the stop never does.
|
|
398
|
+
def stop_outside_signal_handler(launcher)
|
|
399
|
+
stops = Thread::Queue.new
|
|
400
|
+
booted = Thread::Queue.new
|
|
401
|
+
trap_stops = -> { STOP_SIGNALS.each { |name| Signal.trap(name) { stops << name } } }
|
|
402
|
+
|
|
403
|
+
# Replaced where Puma installs its own handlers, which is before it
|
|
404
|
+
# binds the port, so no signal reaches Puma's. setup_signals is private
|
|
405
|
+
# but has kept its name since Puma 2; without it, the handlers are
|
|
406
|
+
# replaced once Puma has booted.
|
|
407
|
+
replaced = launcher.respond_to?(:setup_signals, true)
|
|
408
|
+
if replaced
|
|
409
|
+
launcher.singleton_class.prepend(Module.new do
|
|
410
|
+
define_method(:setup_signals) do
|
|
411
|
+
super()
|
|
412
|
+
trap_stops.call
|
|
413
|
+
end
|
|
414
|
+
private :setup_signals
|
|
415
|
+
end)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
events = launcher.events
|
|
419
|
+
# Puma 7 renamed on_booted; the old name still works there, with a warning.
|
|
420
|
+
events.public_send(events.respond_to?(:after_booted) ? :after_booted : :on_booted) do
|
|
421
|
+
trap_stops.call unless replaced
|
|
422
|
+
booted << true
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
Thread.new do
|
|
426
|
+
stops.pop
|
|
427
|
+
# Puma can stop only a server it has started: the port is bound before
|
|
428
|
+
# the server exists, and a stop asked for in between was dropped.
|
|
429
|
+
booted.pop
|
|
430
|
+
launcher.stop
|
|
431
|
+
sleep STOP_DEADLINE
|
|
432
|
+
$stderr.puts "[rails-ai-context] Server did not stop within #{STOP_DEADLINE}s of the signal; exiting."
|
|
433
|
+
exit!(1)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# What a non-loopback bind does depends on the SDK. One with DNS rebinding
|
|
438
|
+
# protection answers only a loopback Host header, so a client that
|
|
439
|
+
# addresses the machine by IP or name is refused with 403 "Invalid Host
|
|
440
|
+
# header" - while one that sends `Host: localhost` itself gets every tool.
|
|
441
|
+
# The old warning said the bind served the network, which was true of
|
|
442
|
+
# neither kind of client. An SDK without the check does serve everyone.
|
|
443
|
+
def bind_warning(bind)
|
|
444
|
+
prefix = "[rails-ai-context] WARNING: MCP HTTP transport binding to #{bind} opens its port to the network, with no authentication."
|
|
445
|
+
unless host_checked?
|
|
446
|
+
return "#{prefix} Every tool answers whoever reaches it. Use 127.0.0.1 (default) unless you have external auth in place."
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
"#{prefix} The MCP SDK answers only requests whose Host header is 127.0.0.1, ::1 or localhost, so a client that " \
|
|
450
|
+
"addresses this machine by IP or name gets 403 \"Invalid Host header\", while one that sends Host: localhost gets " \
|
|
451
|
+
"every tool. Bind it so for a container whose port is published to the host, where the client still connects to " \
|
|
452
|
+
"localhost; otherwise keep 127.0.0.1 (default)."
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def host_checked?
|
|
456
|
+
MCP::Server::Transports::StreamableHTTPTransport.instance_method(:initialize).parameters.any? { |_, name| name == :allowed_hosts }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Closes every session's stream so a connected client sees the server go,
|
|
460
|
+
# and stops the file watcher, rather than leaving both to process exit.
|
|
461
|
+
def stop_http(transport)
|
|
462
|
+
@live_reload&.stop
|
|
463
|
+
transport&.close
|
|
464
|
+
rescue StandardError => e
|
|
465
|
+
RailsAiContext.debug_fail(e, nil, label: "stop_http")
|
|
241
466
|
end
|
|
242
467
|
|
|
243
468
|
def default_rack_handler
|
|
@@ -256,14 +481,14 @@ module RailsAiContext
|
|
|
256
481
|
# pidfile this server would then write over). Both options are load-bearing:
|
|
257
482
|
# refusing the file still leaves WEB_CONCURRENCY able to start a cluster on
|
|
258
483
|
# its own, and pinning the worker count still lets the file's pidfile and
|
|
259
|
-
# preload_app! through.
|
|
484
|
+
# preload_app! through. The third bounds a stop; see SHUTDOWN_TIMEOUT.
|
|
260
485
|
def rack_handler_options(handler, config)
|
|
261
486
|
options = { Host: config.http_bind, Port: config.http_port }
|
|
262
487
|
return options unless puma_handler?(handler)
|
|
263
488
|
|
|
264
489
|
$stderr.puts "[rails-ai-context] Puma pinned to single mode - MCP sessions are per-process, " \
|
|
265
490
|
"so config/puma.rb and WEB_CONCURRENCY are not read."
|
|
266
|
-
options.merge(workers: 0, config_files: [ "-" ])
|
|
491
|
+
options.merge(workers: 0, config_files: [ "-" ], force_shutdown_after: SHUTDOWN_TIMEOUT)
|
|
267
492
|
end
|
|
268
493
|
|
|
269
494
|
# The handler is whatever Rackup picked, and only Puma understands these
|
|
@@ -277,28 +502,36 @@ module RailsAiContext
|
|
|
277
502
|
# :auto - try to load `listen`, print a tip to stderr if missing
|
|
278
503
|
# true - try to load `listen`, raise if missing
|
|
279
504
|
# false - skip entirely
|
|
505
|
+
#
|
|
506
|
+
# Either way each tool call checks the app's files itself
|
|
507
|
+
# (BaseTool.refresh_if_files_changed!), so answers follow edits made
|
|
508
|
+
# before the call; the watch adds telling clients that files changed.
|
|
280
509
|
def maybe_start_live_reload(mcp_server)
|
|
281
510
|
mode = RailsAiContext.configuration.live_reload
|
|
282
|
-
|
|
511
|
+
CodeReloader.track_loaded_code!
|
|
512
|
+
Tools::BaseTool.check_files_per_call!(app)
|
|
283
513
|
return if mode == false
|
|
284
514
|
|
|
285
515
|
begin
|
|
286
516
|
live_reload = LiveReload.new(app, mcp_server)
|
|
287
|
-
live_reload.start
|
|
288
|
-
@live_reload = live_reload
|
|
517
|
+
@live_reload = live_reload if live_reload.start
|
|
289
518
|
rescue LoadError
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
519
|
+
# A standalone install reaches an installed listen; the app's Gemfile
|
|
520
|
+
# is the place only when the gem itself is in it.
|
|
521
|
+
remedy = InstallMode.standalone? ? "Install it: gem install listen" : "Add to your Gemfile: gem 'listen', group: :development"
|
|
522
|
+
raise LoadError, "Live reload requires the `listen` gem. #{remedy}" if mode == true
|
|
523
|
+
|
|
524
|
+
# :auto mode - skip with a tip. Answers still follow edits: each tool
|
|
525
|
+
# call checks the app's files.
|
|
526
|
+
$stderr.puts "[rails-ai-context] Live reload off: no `listen` gem, so clients are not told when files change. #{remedy}"
|
|
296
527
|
end
|
|
297
528
|
end
|
|
298
529
|
|
|
299
530
|
def build_rack_app(transport)
|
|
300
531
|
lambda do |env|
|
|
301
|
-
McpEdge.
|
|
532
|
+
if McpEdge.mcp_request?(env)
|
|
533
|
+
McpEdge.serve(env, transport)
|
|
534
|
+
else
|
|
302
535
|
[ 404, { "Content-Type" => "application/json" }, [ '{"error":"Not found"}' ] ]
|
|
303
536
|
end
|
|
304
537
|
end
|