parse-stack-next 4.5.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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,1143 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "securerandom"
6
+ require_relative "errors"
7
+ require_relative "mcp_dispatcher"
8
+ require_relative "cancellation_token"
9
+
10
+ module Parse
11
+ class Agent
12
+ # Rack adapter that exposes Parse::Agent::MCPDispatcher as a mountable
13
+ # Rack endpoint. Downstream applications can mount this inside Sinatra,
14
+ # Rails, or any Rack-compatible router at an arbitrary path and behind
15
+ # their own authentication gate.
16
+ #
17
+ # The adapter enforces the same transport-level invariants as MCPServer
18
+ # (method, content-type, body-size, and JSON-parse checks) and then
19
+ # delegates to Parse::Agent::MCPDispatcher.call for all protocol handling.
20
+ #
21
+ # == SSE Streaming (MCP progress notifications)
22
+ #
23
+ # When constructed with `streaming: true`, requests that include
24
+ # `Accept: text/event-stream` receive an SSE response instead of a single
25
+ # JSON body. The server holds the connection open and emits
26
+ # `notifications/progress` events from two sources:
27
+ #
28
+ # 1. Time-based heartbeats every `heartbeat_interval` seconds while
29
+ # the dispatcher runs (progress field = elapsed seconds).
30
+ # 2. Tool-internal progress reported by the tool itself via
31
+ # `agent.report_progress(progress:, total:, message:)`. Works for
32
+ # both built-in tools and custom tools registered through
33
+ # `Parse::Agent::Tools.register`.
34
+ #
35
+ # Heartbeats are automatically suppressed once a tool reports its own
36
+ # progress, so the `progressToken` carries a single coherent stream.
37
+ # A final `response` event carries the complete JSON-RPC response,
38
+ # after which the stream closes.
39
+ #
40
+ # This lets LLM clients observe progress on long-running tool calls (such
41
+ # as aggregate pipelines) rather than timing out silently.
42
+ #
43
+ # Streaming requires a Rack server that supports streaming response bodies
44
+ # (Puma, Falcon, Unicorn). WEBrick buffers the full body before writing,
45
+ # so SSE streaming has no effect on the standalone MCPServer — operators
46
+ # using MCPServer directly should leave `streaming: false` (the default).
47
+ #
48
+ # To disable Nginx response buffering for SSE endpoints, set:
49
+ # proxy_buffering off;
50
+ # or rely on the `X-Accel-Buffering: no` header this class emits
51
+ # automatically on every SSE response.
52
+ #
53
+ # When `streaming: false` (default), an `Accept: text/event-stream` request
54
+ # receives a plain JSON response — the adapter is permissive per the MCP
55
+ # spec, which does not require SSE support.
56
+ #
57
+ # @example Block form (most common)
58
+ # app = Parse::Agent::MCPRackApp.new do |env|
59
+ # token = env["HTTP_AUTHORIZATION"].to_s.delete_prefix("Bearer ")
60
+ # agent = MyAuth.agent_for_token!(token) # raises Unauthorized if invalid
61
+ # agent
62
+ # end
63
+ #
64
+ # @example Keyword argument form
65
+ # factory = ->(env) { Parse::Agent.new(permissions: :readonly) }
66
+ # app = Parse::Agent::MCPRackApp.new(agent_factory: factory)
67
+ #
68
+ # @example With SSE streaming enabled
69
+ # app = Parse::Agent::MCPRackApp.new(streaming: true) { |env| ... }
70
+ #
71
+ # @example Mounted in Rails routes.rb
72
+ # mount Parse::Agent::MCPRackApp.new { |env| ... }, at: "/mcp"
73
+ #
74
+ class MCPRackApp
75
+ # Maximum allowed request body size in bytes (matches MCPServer::MAX_BODY_SIZE).
76
+ DEFAULT_MAX_BODY_SIZE = 1_048_576 # 1 MB
77
+
78
+ # JSON nesting depth limit (matches MCPServer::MAX_JSON_NESTING).
79
+ MAX_JSON_NESTING = 20
80
+
81
+ # Default heartbeat interval in seconds when streaming is enabled.
82
+ DEFAULT_HEARTBEAT_INTERVAL = 2
83
+
84
+ # Standard Content-Type for all JSON responses. Frozen template — call
85
+ # {#json_headers} to obtain a per-response mutable copy that composes
86
+ # with Rack middleware that decorates response headers (e.g. Sinatra's
87
+ # xss_header / json_csrf / common_logger).
88
+ JSON_CONTENT_TYPE = { "Content-Type" => "application/json" }.freeze
89
+
90
+ # SSE response headers. X-Accel-Buffering disables Nginx proxy buffering.
91
+ # Frozen template — call {#sse_headers} to obtain a per-response copy.
92
+ SSE_HEADERS = {
93
+ "Content-Type" => "text/event-stream",
94
+ "Cache-Control" => "no-cache",
95
+ "Connection" => "keep-alive",
96
+ "X-Accel-Buffering" => "no",
97
+ }.freeze
98
+
99
+ # Drop env keys that would have come from underscore-form HTTP header
100
+ # names. The Rack-spec-compliant interpretation of HTTP headers maps
101
+ # `X-MCP-API-Key` and `X_MCP_API_KEY` to the same env key
102
+ # (`HTTP_X_MCP_API_KEY`); a misbehaving upstream server that forwards
103
+ # the underscore-form lets an attacker overwrite trusted reverse-proxy-
104
+ # injected headers.
105
+ #
106
+ # This helper is invoked automatically at the top of {#call}, so any
107
+ # MCPRackApp mounted in a Rack 3+ pipeline (which exposes the original
108
+ # header list via `rack.headers`) gets defense-in-depth scrubbing
109
+ # without operator opt-in. On Rack 2 / pre-3 servers `rack.headers` is
110
+ # not set and the helper is a no-op; operators on those stacks must
111
+ # configure their upstream (e.g. Nginx `underscores_in_headers off`)
112
+ # OR mount this helper as an explicit middleware.
113
+ #
114
+ # The standalone `MCPServer` rewrites its own `build_rack_env` to drop
115
+ # underscore-form names before they reach this app, so the standalone
116
+ # path is covered regardless of Rack version.
117
+ #
118
+ # @example Explicit middleware (Rack 2 / pre-3 deployments)
119
+ # class StripSmuggledHeaders
120
+ # def initialize(app); @app = app; end
121
+ # def call(env)
122
+ # Parse::Agent::MCPRackApp.strip_underscore_smuggled_headers!(env)
123
+ # @app.call(env)
124
+ # end
125
+ # end
126
+ #
127
+ # @param env [Hash] the Rack env, mutated in place
128
+ # @return [Hash] the same env, for chaining
129
+ def self.strip_underscore_smuggled_headers!(env)
130
+ # Rack 3+ preserves the original header list in env["rack.headers"]
131
+ # (a Rack::Headers instance or Hash). When present, we can identify
132
+ # which env keys came from an underscore-form header and delete
133
+ # them, even if a dashed-form sibling arrived too.
134
+ if env["rack.headers"].respond_to?(:each)
135
+ suspect = []
136
+ env["rack.headers"].each do |name, _|
137
+ suspect << name if name.is_a?(String) && name.include?("_")
138
+ end
139
+ suspect.each do |name|
140
+ env.delete("HTTP_#{name.upcase.tr("-", "_")}")
141
+ end
142
+ end
143
+ env
144
+ end
145
+
146
+ # @param agent_factory [Proc, nil] callable invoked with the Rack env on
147
+ # every request. Must return a Parse::Agent or raise
148
+ # Parse::Agent::Unauthorized. Mutually exclusive with a block.
149
+ # @param max_body_size [Integer] reject bodies larger than this many bytes.
150
+ # Defaults to DEFAULT_MAX_BODY_SIZE.
151
+ # @param logger [#warn, nil] optional logger. When set, auth failures are
152
+ # warned at class-name level, and internal errors include a backtrace.
153
+ # @param streaming [Boolean] enable SSE streaming for clients that send
154
+ # `Accept: text/event-stream`. Defaults to false for backward
155
+ # compatibility. Has no effect on WEBrick-backed deployments (see
156
+ # class documentation).
157
+ # @param heartbeat_interval [Numeric] seconds between progress heartbeat
158
+ # events when streaming is active. Defaults to DEFAULT_HEARTBEAT_INTERVAL.
159
+ # Ignored when `streaming: false`.
160
+ # @param max_concurrent_dispatchers [Integer, nil] when set, limits the
161
+ # number of concurrently active dispatcher threads across all SSE
162
+ # connections served by this app instance. When the limit is reached a
163
+ # new SSE request immediately receives a 503 JSON-RPC error envelope
164
+ # (`-32000` "server busy") rather than spawning another dispatcher.
165
+ # Defaults to `nil` (unlimited). Use `active_dispatcher_count` to
166
+ # monitor current concurrency from operator tooling.
167
+ # @param pre_auth_rate_limiter [#check!, nil] optional rate limiter
168
+ # consulted at the top of every request, BEFORE the agent_factory is
169
+ # invoked. Closes the factory-amplification DoS where each malformed
170
+ # request burns a Parse Server round-trip (factories typically
171
+ # validate session tokens by calling out). Must respond to `#check!`
172
+ # and raise an exception responding to `#retry_after` (such as
173
+ # `Parse::Agent::RateLimiter::RateLimitExceeded`) when exhausted.
174
+ # Defaults to `nil` (no pre-auth limiter). On exhaustion the request
175
+ # is rejected with HTTP 429 and a `Retry-After` header.
176
+ # @param allowed_origins [Array<String>, nil] when set, the `Origin`
177
+ # request header must match one of these entries (case-insensitive,
178
+ # exact host match — wildcard via leading `.` matches subdomains).
179
+ # `nil` (default) skips the check. Browsers always send `Origin`
180
+ # on cross-origin POST; native clients (curl, ruby HTTP client,
181
+ # SDK-to-SDK) typically don't, and an absent `Origin` is treated
182
+ # as allowed regardless of this setting. The default loopback
183
+ # bind makes this check optional in development; operators who
184
+ # bind MCP to a routable interface should configure it.
185
+ # @param require_custom_header [String, nil] when set (e.g.
186
+ # `"X-MCP-Client"`), requests must carry that header with any
187
+ # non-empty value. Custom headers can't be set by a `<form>`
188
+ # CSRF and force a CORS preflight on browser `fetch()`, so this
189
+ # gate closes the browser-driven attack surface entirely. Pair
190
+ # with `allowed_origins` for defense in depth.
191
+ # @raise [ArgumentError] if both or neither of agent_factory/block are given.
192
+ def initialize(agent_factory: nil, max_body_size: DEFAULT_MAX_BODY_SIZE,
193
+ logger: nil, streaming: false,
194
+ heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
195
+ max_concurrent_dispatchers: nil,
196
+ pre_auth_rate_limiter: nil,
197
+ allowed_origins: nil,
198
+ require_custom_header: nil, &block)
199
+ if agent_factory && block
200
+ raise ArgumentError, "Provide agent_factory: OR a block, not both"
201
+ end
202
+ unless agent_factory || block
203
+ raise ArgumentError, "Either agent_factory: keyword or a block is required"
204
+ end
205
+ if pre_auth_rate_limiter && !pre_auth_rate_limiter.respond_to?(:check!)
206
+ raise ArgumentError, "pre_auth_rate_limiter must respond to #check!"
207
+ end
208
+
209
+ @agent_factory = agent_factory || block
210
+ @max_body_size = max_body_size
211
+ @logger = logger
212
+ @streaming = streaming
213
+ @heartbeat_interval = heartbeat_interval
214
+ @max_concurrent_dispatchers = max_concurrent_dispatchers
215
+ @pre_auth_rate_limiter = pre_auth_rate_limiter
216
+ @allowed_origins = normalize_allowed_origins(allowed_origins)
217
+ @required_custom_header = normalize_required_custom_header(require_custom_header)
218
+ # Per-app registry of in-flight cancellable requests. Keyed by
219
+ # [correlation_id, request_id]. A `notifications/cancelled` POST
220
+ # whose `params.requestId` matches an entry trips the registered
221
+ # CancellationToken. Scoped per-instance, not per-process: this
222
+ # registry does not span multiple MCPRackApp mount points within
223
+ # a process, nor multiple processes in a clustered deployment.
224
+ @cancellation_registry = CancellationRegistry.new
225
+
226
+ # Warn operators who enable streaming without a concurrency cap.
227
+ # An unbounded SSE endpoint with orphaned dispatcher threads is
228
+ # a practical DoS surface — a slow or hostile client opening
229
+ # connections faster than tools complete can exhaust the host's
230
+ # thread pool and downstream Parse connection pool. Leaving the
231
+ # default as `nil` (unlimited) preserves backward compatibility,
232
+ # but we tell the operator once at construction.
233
+ if streaming && @max_concurrent_dispatchers.nil?
234
+ line = "[Parse::Agent::MCPRackApp] streaming: true with max_concurrent_dispatchers: nil (unlimited). " \
235
+ "Set a finite cap (e.g. 100, or 2x your Puma max_threads) to bound the orphan-thread DoS surface. " \
236
+ "See docs/mcp_guide.md for sizing guidance."
237
+ if @logger
238
+ @logger.warn(line)
239
+ else
240
+ warn line
241
+ end
242
+ end
243
+ end
244
+
245
+ # Returns the number of currently live dispatcher threads spawned by any
246
+ # SSEBody across all MCPRackApp instances in this process. Threads are
247
+ # counted by the `:parse_mcp_dispatcher` thread-local tag set when each
248
+ # dispatcher_thread is started. Use this for operator dashboards or health
249
+ # checks; do NOT use it to make flow-control decisions at runtime (use
250
+ # the `max_concurrent_dispatchers:` constructor option for that).
251
+ def self.active_dispatcher_count
252
+ Thread.list.count { |t| t[:parse_mcp_dispatcher] }
253
+ end
254
+
255
+ # Rack interface.
256
+ #
257
+ # @param env [Hash] Rack environment
258
+ # @return [Array(Integer, Hash, #each)] Rack triple
259
+ def call(env)
260
+ # 0. Defense-in-depth: strip underscore-form HTTP headers from env
261
+ # before any subsequent lookup reads HTTP_X_MCP_API_KEY / etc.
262
+ # No-op on Rack < 3 (where env["rack.headers"] is absent); on
263
+ # Rack 3+ this removes any HTTP_* env key whose original header
264
+ # name contained an underscore. Closes the smuggling path where
265
+ # a hostile client sends `X_MCP_API_Key: ...` alongside a
266
+ # trusted reverse-proxy-injected `X-MCP-API-Key: ...` and the
267
+ # underscored form collapses-and-overwrites the trusted slot.
268
+ self.class.strip_underscore_smuggled_headers!(env)
269
+
270
+ # 0b. NEW-MCP-6: pre-auth rate limit. Runs BEFORE the agent_factory
271
+ # so a malformed body / missing key / empty `{}` cannot force
272
+ # the operator-supplied factory to round-trip to Parse Server
273
+ # on every request. Off by default (constructor kwarg).
274
+ if @pre_auth_rate_limiter
275
+ begin
276
+ @pre_auth_rate_limiter.check!
277
+ rescue StandardError => e
278
+ retry_after = e.respond_to?(:retry_after) ? e.retry_after : nil
279
+ headers = json_headers.dup
280
+ headers["Retry-After"] = retry_after.ceil.to_s if retry_after && retry_after > 0
281
+ return [429, headers, [json_rpc_error(-32_000, "Too Many Requests")]]
282
+ end
283
+ end
284
+
285
+ # 1. Method check — only POST is accepted.
286
+ unless env["REQUEST_METHOD"] == "POST"
287
+ return [405,
288
+ json_headers.merge("Allow" => "POST"),
289
+ [json_rpc_error(-32_700, "method_not_allowed")]]
290
+ end
291
+
292
+ # 2. Content-type check — must be application/json (charset ignored).
293
+ content_type = env["CONTENT_TYPE"].to_s.split(";").first.to_s.strip.downcase
294
+ unless content_type == "application/json"
295
+ return [415, json_headers, [json_rpc_error(-32_700, "Unsupported Media Type: Content-Type must be application/json")]]
296
+ end
297
+
298
+ # 2b. Origin allowlist. Browsers always send an `Origin` header
299
+ # on cross-origin POST; native clients typically don't.
300
+ # When configured, a non-empty `Origin` must match the
301
+ # allowlist or the request is rejected with 403.
302
+ # Missing/empty `Origin` is allowed regardless — native
303
+ # clients (curl, SDK-to-SDK) shouldn't be broken by a
304
+ # CSRF defense aimed at browsers.
305
+ if @allowed_origins
306
+ origin = env["HTTP_ORIGIN"].to_s.strip
307
+ unless origin.empty? || origin_allowed?(origin)
308
+ @logger&.warn("[Parse::Agent::MCPRackApp] Origin refused: #{origin.inspect}")
309
+ return [403, json_headers, [json_rpc_error(-32_700, "Origin not allowed")]]
310
+ end
311
+ end
312
+
313
+ # 2c. Required custom header (CSRF defense-in-depth). A header
314
+ # like `X-MCP-Client` cannot be set by a `<form>` CSRF and
315
+ # forces a CORS preflight on browser `fetch()`. When
316
+ # configured, the header must be present and (if a value
317
+ # was supplied to the constructor) match.
318
+ if @required_custom_header
319
+ header_env_key, expected_value = @required_custom_header
320
+ actual = env[header_env_key].to_s
321
+ if actual.empty? || (expected_value && actual != expected_value)
322
+ return [403, json_headers, [json_rpc_error(-32_700, "Required custom header missing or invalid")]]
323
+ end
324
+ end
325
+
326
+ # 3. Body size limit — read one byte beyond limit to detect oversized bodies
327
+ # without buffering the full stream.
328
+ raw_body = env["rack.input"].read(@max_body_size + 1)
329
+ if raw_body.bytesize > @max_body_size
330
+ return [413, json_headers, [json_rpc_error(-32_700, "Payload Too Large: body exceeds #{@max_body_size} bytes")]]
331
+ end
332
+
333
+ # 4. JSON parse.
334
+ begin
335
+ body = JSON.parse(raw_body.empty? ? "{}" : raw_body, max_nesting: MAX_JSON_NESTING)
336
+ rescue JSON::ParserError, JSON::NestingError
337
+ return [400, json_headers, [json_rpc_error(-32_700, "Parse error: invalid JSON")]]
338
+ end
339
+
340
+ # 4b. NEW-MCP-6: refuse obviously-malformed JSON-RPC envelopes
341
+ # BEFORE invoking the agent_factory. The factory typically
342
+ # hits Parse Server (token validation, audit logging), so a
343
+ # barrage of empty `{}` or missing-method bodies otherwise
344
+ # amplifies into a Parse Server load problem. Empty-object
345
+ # and missing-method requests cannot possibly be valid
346
+ # JSON-RPC, so we shortcut to -32600 (Invalid Request).
347
+ unless body.is_a?(Hash) && body["method"].is_a?(String) && !body["method"].empty?
348
+ return [400, json_headers, [json_rpc_error(-32_600, "Invalid Request")]]
349
+ end
350
+
351
+ # 5. Agent factory — auth gate. Rescue Unauthorized first, then catch-all
352
+ # for unexpected factory errors.
353
+ begin
354
+ agent = @agent_factory.call(env)
355
+ rescue Parse::Agent::Unauthorized => e
356
+ @logger.warn("[Parse::Agent::MCPRackApp] Unauthorized: #{e.class.name}") if @logger
357
+ return [401, json_headers, [unauthorized_body]]
358
+ rescue StandardError => e
359
+ if @logger
360
+ @logger.warn("[Parse::Agent::MCPRackApp] Factory error: #{e.class.name}")
361
+ @logger.warn(e.backtrace.join("\n")) if e.backtrace
362
+ end
363
+ return [500, json_headers, [json_rpc_error(-32_603, "Internal error")]]
364
+ end
365
+
366
+ # 5b. Thread the conversation correlation id through. Source:
367
+ # X-MCP-Session-Id header. Only fills it when the factory
368
+ # hasn't already assigned one — application code that needs to
369
+ # override the client-supplied id (e.g., bind to an internal
370
+ # session record) can do so in the factory and we don't
371
+ # stomp on it. The Parse::Agent#correlation_id= setter
372
+ # sanitizes the value; an invalid header is silently dropped.
373
+ if agent && agent.respond_to?(:correlation_id=) &&
374
+ agent.correlation_id.nil? &&
375
+ (sid = env["HTTP_X_MCP_SESSION_ID"])
376
+ agent.correlation_id = sid
377
+ end
378
+
379
+ # 5c. notifications/cancelled — special-cased BEFORE the dispatcher.
380
+ # A JSON-RPC notification has no `id`, expects no response
381
+ # body, and must trip the in-flight request whose
382
+ # `(correlation_id, request_id)` matches. We require the
383
+ # cancelling request to carry the same X-MCP-Session-Id
384
+ # (sanitized into agent.correlation_id above) as the original
385
+ # request — otherwise an attacker who guesses sequential
386
+ # JSON-RPC ids could cancel arbitrary in-flight requests.
387
+ #
388
+ # Failures (no correlation_id, no requestId, no match) are
389
+ # silent no-ops to avoid a probe oracle. The response is
390
+ # always 202 Accepted with an empty body.
391
+ if body.is_a?(Hash) && body["method"] == "notifications/cancelled"
392
+ request_id = body.dig("params", "requestId")
393
+ if agent.respond_to?(:correlation_id) && agent.correlation_id && request_id
394
+ @cancellation_registry.cancel(
395
+ agent.correlation_id,
396
+ request_id,
397
+ reason: :notifications_cancelled,
398
+ )
399
+ end
400
+ return [202, json_headers, [""]]
401
+ end
402
+
403
+ # 6. Branch on streaming preference. Transport-level errors (steps 1-5)
404
+ # always return plain JSON regardless of the Accept header.
405
+ if @streaming && env["HTTP_ACCEPT"].to_s.include?("text/event-stream")
406
+ serve_sse(body, agent)
407
+ else
408
+ serve_json(body, agent)
409
+ end
410
+ end
411
+
412
+ private
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # Response paths
416
+ # ---------------------------------------------------------------------------
417
+
418
+ # Dispatch synchronously and return a single JSON Rack response.
419
+ #
420
+ # @param body [Hash] parsed JSON-RPC request body.
421
+ # @param agent [Parse::Agent] authenticated agent.
422
+ # @return [Array] Rack triple with Array<String> body.
423
+ def serve_json(body, agent)
424
+ result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent, logger: @logger)
425
+ # When the dispatcher returns body: nil (a JSON-RPC notification
426
+ # like notifications/cancelled has no response), the Rack body
427
+ # is an empty string — NOT the literal "null". The HTTP-level
428
+ # success/empty-body shape is what the spec calls for and is
429
+ # what every MCP client expects after sending a notification.
430
+ return [result[:status], json_headers, [""]] if result[:body].nil?
431
+
432
+ [result[:status], json_headers, [JSON.generate(result[:body])]]
433
+ end
434
+
435
+ # Return a streaming Rack response that emits SSE progress events while
436
+ # the dispatcher runs, followed by a final `response` event.
437
+ #
438
+ # The response body is an SSEBody instance whose `#each` method blocks
439
+ # (reading from an internal Queue) until the worker thread signals
440
+ # completion. All `yield` calls happen on the thread/fiber that drives
441
+ # `#each` (the Rack server's I/O thread); the worker thread only pushes
442
+ # to the Queue, avoiding Fiber cross-thread violations.
443
+ #
444
+ # When `max_concurrent_dispatchers` is set and the current count of live
445
+ # dispatcher threads meets or exceeds that limit, the request is rejected
446
+ # immediately with a 503 JSON-RPC error (-32000 "server busy") rather
447
+ # than spawning another dispatcher thread. The check is performed here
448
+ # (before SSEBody is constructed) so the 503 is returned as a plain JSON
449
+ # response triple, not as an SSE stream.
450
+ #
451
+ # @param body [Hash] parsed JSON-RPC request body.
452
+ # @param agent [Parse::Agent] authenticated agent.
453
+ # @return [Array] Rack triple with SSEBody or a 503 JSON error as the body.
454
+ def serve_sse(body, agent)
455
+ # NOTE: this check is not mutex-protected, so two concurrent requests
456
+ # arriving within the same scheduling quantum can both pass the check
457
+ # and each spawn a dispatcher_thread, briefly exceeding the limit by
458
+ # one slot. The check is a best-effort soft cap, not a hard guarantee.
459
+ # This is intentional — mutex overhead on the hot path is undesirable,
460
+ # and brief overrun by 1 is acceptable under Puma's thread-per-request
461
+ # model.
462
+ if @max_concurrent_dispatchers &&
463
+ MCPRackApp.active_dispatcher_count >= @max_concurrent_dispatchers
464
+ return [503, json_headers,
465
+ [json_rpc_error(-32_000, "server busy", id: body["id"])]]
466
+ end
467
+
468
+ progress_token = body.dig("params", "_meta", "progressToken") || SecureRandom.uuid
469
+ req_id = body["id"]
470
+ interval = @heartbeat_interval
471
+ logger = @logger
472
+
473
+ # Register a cancellation token in the per-app registry so a
474
+ # subsequent notifications/cancelled with a matching
475
+ # (correlation_id, request_id) can trip it. Registration happens
476
+ # synchronously here — BEFORE SSEBody spawns the dispatcher_thread
477
+ # in #each — so a fast-arriving cancel from the same client cannot
478
+ # race against an empty registry.
479
+ #
480
+ # The registry hands back an opaque entry_id; on_close passes it
481
+ # to deregister so a sibling request that reused the same
482
+ # (correlation_id, request_id) key cannot have its token evicted
483
+ # when this request closes.
484
+ cancellation_token = Parse::Agent::CancellationToken.new
485
+ correlation_id = agent.respond_to?(:correlation_id) ? agent.correlation_id : nil
486
+ registry_entry_id = @cancellation_registry.register(correlation_id, req_id, cancellation_token)
487
+ registry = @cancellation_registry
488
+
489
+ # The block receives the SSEBody's progress_callback so tools can
490
+ # emit `notifications/progress` events through it. The callback is
491
+ # safe to pass even when no tool calls it — SSEBody only writes to
492
+ # the queue when invoked, and the JSON path never reaches this code.
493
+ sse_body = SSEBody.new(
494
+ progress_token, req_id, interval, logger,
495
+ cancellation_token: cancellation_token,
496
+ on_close: -> { registry.deregister(correlation_id, req_id, registry_entry_id) if registry_entry_id },
497
+ ) do |progress_callback|
498
+ Parse::Agent::MCPDispatcher.call(
499
+ body: body,
500
+ agent: agent,
501
+ logger: logger,
502
+ progress_callback: progress_callback,
503
+ cancellation_token: cancellation_token,
504
+ )
505
+ end
506
+
507
+ [200, sse_headers, sse_body]
508
+ end
509
+
510
+ # ---------------------------------------------------------------------------
511
+ # SSE body class
512
+ # ---------------------------------------------------------------------------
513
+
514
+ # Rack body object that emits MCP progress notifications over SSE.
515
+ #
516
+ # `#each` is the only public interface (besides `#close`). It is driven
517
+ # by the Rack server on whatever thread/fiber handles response writing.
518
+ # The dispatcher call and heartbeat timer both run on a dedicated worker
519
+ # thread so they do not block the calling fiber.
520
+ #
521
+ # == Two sources of progress events
522
+ #
523
+ # SSEBody emits `notifications/progress` events from two sources:
524
+ #
525
+ # 1. **Time-based heartbeats.** The worker thread emits a heartbeat
526
+ # every `@interval` seconds while the dispatcher is running. The
527
+ # `progress` field is elapsed seconds; `total` is omitted. The
528
+ # heartbeat uses a dedicated server-generated `progressToken`
529
+ # distinct from any client-supplied token so the elapsed-seconds
530
+ # scale never appears alongside tool-reported work units on the
531
+ # same token (the MCP spec requires per-token monotonicity).
532
+ #
533
+ # 2. **Tool-internal progress.** Tools call `agent.report_progress(...)`
534
+ # which invokes the callback exposed by `#progress_callback`. The
535
+ # callback pushes an event using the client-supplied or
536
+ # server-generated `progressToken` with the tool-supplied
537
+ # `progress`, optional `total`, and optional `message`.
538
+ #
539
+ # Once a tool starts reporting its own progress, the heartbeat
540
+ # loop suppresses further time-based events to reduce stream
541
+ # noise — the tool's reports already carry liveness signal. When
542
+ # the tool never calls `report_progress`, heartbeats continue
543
+ # firing for the lifetime of the dispatcher.
544
+ #
545
+ # Wire format for each SSE event (note: trailing blank line is required
546
+ # by the SSE spec):
547
+ #
548
+ # event: progress\n
549
+ # data: <json>\n
550
+ # \n
551
+ #
552
+ # @api private
553
+ class SSEBody
554
+ # Sentinel pushed to the queue when the worker is done.
555
+ DONE = :__sse_done__
556
+
557
+ # Callback exposed to the dispatcher block. Calling this with
558
+ # keyword args `progress:`, `total:`, `message:` pushes a
559
+ # tool-progress `notifications/progress` event to the SSE queue
560
+ # and marks the worker as "tool is reporting" so subsequent
561
+ # time-based heartbeats are suppressed.
562
+ #
563
+ # @return [Proc]
564
+ attr_reader :progress_callback
565
+
566
+ # @param progress_token [String] MCP progressToken value.
567
+ # @param req_id [Object] JSON-RPC request id (may be nil).
568
+ # @param interval [Numeric] heartbeat period in seconds.
569
+ # @param logger [#warn, nil] optional logger.
570
+ # @param cancellation_token [Parse::Agent::CancellationToken, nil]
571
+ # token tripped by {#close} (client disconnect) and by
572
+ # `notifications/cancelled` lookups. Tools cooperate by checking
573
+ # `agent.cancelled?`.
574
+ # @param on_close [Proc, nil] callback invoked from {#close} after
575
+ # the worker has been terminated. Used by MCPRackApp to
576
+ # deregister the cancellation token from the per-app registry.
577
+ # @param dispatcher_blk [Proc] called with one argument (the
578
+ # {#progress_callback} Proc); must return the same
579
+ # `{ status:, body: }` hash that MCPDispatcher.call returns.
580
+ def initialize(progress_token, req_id, interval, logger,
581
+ cancellation_token: nil, on_close: nil, &dispatcher_blk)
582
+ @progress_token = progress_token
583
+ # Heartbeats use a dedicated server-generated progressToken so
584
+ # the elapsed-seconds scale of heartbeats never appears on the
585
+ # same MCP progressToken as work-unit values reported by tools.
586
+ # The MCP spec requires `progress` to increase monotonically
587
+ # per progressToken; mixing the two scales would violate it
588
+ # at the boundary where a tool first reports.
589
+ @heartbeat_token = "parse-stack:heartbeat:#{SecureRandom.uuid}"
590
+ @req_id = req_id
591
+ @interval = interval
592
+ @logger = logger
593
+ @dispatcher_blk = dispatcher_blk
594
+ @cancellation_token = cancellation_token
595
+ @on_close = on_close
596
+ @queue = Queue.new
597
+ @worker = nil
598
+ # Flipped to true by #each when the DONE sentinel is consumed.
599
+ # #close uses this to decide whether to trip the cancellation
600
+ # token (false = client disconnect) or skip the trip (true =
601
+ # the request finished on its own). Reads and writes happen
602
+ # under @close_mutex below.
603
+ @completed_normally = false
604
+ # Volatile flag flipped by the progress_callback the first time a
605
+ # tool reports. Heartbeats now use a separate progressToken so
606
+ # the flag is no longer a spec-correctness gate, but we keep
607
+ # it as a small bandwidth optimization — once a tool is
608
+ # actively reporting, time-based heartbeats are noise.
609
+ @tool_progress_reported = false
610
+ @progress_callback = build_progress_callback
611
+ # Deregistration callbacks for the Tools/Prompts subscribe
612
+ # bindings. Set when the worker starts (so a request that is
613
+ # never driven via #each does not register a stale entry) and
614
+ # cleared in #close.
615
+ @unsubscribe_tools = nil
616
+ @unsubscribe_prompts = nil
617
+ # Guards concurrent invocations of #close. Rack servers
618
+ # sometimes call close from both the I/O fiber's ensure and a
619
+ # separate disconnect-handler thread; without a mutex the
620
+ # subscriber-deregister and on_close paths can run twice.
621
+ @close_mutex = Mutex.new
622
+ @closed = false
623
+ end
624
+
625
+ # Rack body interface — called once by the Rack server.
626
+ #
627
+ # Starts a worker thread that runs the dispatcher and emits periodic
628
+ # heartbeats via the queue, then loops reading from the queue and
629
+ # yielding formatted SSE strings until the final response is sent.
630
+ #
631
+ # @yield [String] SSE-formatted event strings.
632
+ def each
633
+ start_worker
634
+ loop do
635
+ msg = @queue.pop
636
+ if msg == DONE
637
+ @close_mutex.synchronize { @completed_normally = true }
638
+ break
639
+ end
640
+ yield msg
641
+ end
642
+ ensure
643
+ close
644
+ end
645
+
646
+ # Terminate the stream and clean up.
647
+ #
648
+ # When called BEFORE the stream completed normally (the DONE
649
+ # sentinel was not consumed by {#each}), this is interpreted as
650
+ # a client disconnect and:
651
+ #
652
+ # 1. The cancellation token (if any) is tripped BEFORE the
653
+ # worker is killed, so tools that observe `agent.cancelled?`
654
+ # at a checkpoint can exit cooperatively. The kill becomes
655
+ # the fallback for tools stuck inside a blocking I/O call.
656
+ #
657
+ # When called AFTER normal completion, the token is NOT tripped
658
+ # — the request finished on its own; cancellation would only
659
+ # confuse a tool that races to check the flag.
660
+ #
661
+ # Either path:
662
+ # - Kills the worker thread if still alive.
663
+ # - Invokes the on_close hook so MCPRackApp can deregister
664
+ # the token from its per-app registry. Failures in the hook
665
+ # are logged and swallowed — close must always succeed.
666
+ #
667
+ # Cancellation note: blocking I/O calls (MongoDB query, Parse
668
+ # REST roundtrip) do not observe the token until they return.
669
+ # The Ruby-level `Timeout.timeout` already wrapping each tool is
670
+ # the hard upper bound on wasted work; cancellation reduces it,
671
+ # not eliminates it.
672
+ def close
673
+ # Idempotent — concurrent invocations from the I/O fiber and
674
+ # a disconnect-handler thread short-circuit after the first
675
+ # caller wins the mutex.
676
+ completed_normally = nil
677
+ @close_mutex.synchronize do
678
+ return if @closed
679
+ @closed = true
680
+ completed_normally = @completed_normally
681
+ end
682
+ unless completed_normally
683
+ @cancellation_token&.cancel!(reason: :client_disconnect)
684
+ end
685
+ @worker&.kill if @worker&.alive?
686
+ @worker = nil
687
+ # Deregister listChanged subscribers BEFORE the on_close hook
688
+ # so a subsequent registry mutation cannot push events into
689
+ # the queue after the stream has ended.
690
+ begin
691
+ @unsubscribe_tools&.call
692
+ @unsubscribe_prompts&.call
693
+ rescue StandardError => e
694
+ line = "[Parse::Agent::MCPRackApp::SSEBody] unsubscribe error: #{e.class}: #{e.message}"
695
+ if @logger
696
+ @logger.warn(line)
697
+ else
698
+ warn line
699
+ end
700
+ ensure
701
+ @unsubscribe_tools = nil
702
+ @unsubscribe_prompts = nil
703
+ end
704
+ if @on_close
705
+ begin
706
+ @on_close.call
707
+ rescue StandardError => e
708
+ line = "[Parse::Agent::MCPRackApp::SSEBody] on_close error: #{e.class}: #{e.message}"
709
+ if @logger
710
+ @logger.warn(line)
711
+ else
712
+ warn line
713
+ end
714
+ end
715
+ end
716
+ @on_close = nil
717
+ end
718
+
719
+ private
720
+
721
+ def start_worker
722
+ # Subscribe to listChanged events BEFORE spawning the worker
723
+ # so any registry mutation that races with the start of the
724
+ # stream is captured. The callbacks push the corresponding
725
+ # MCP notification onto the same queue the worker writes to.
726
+ queue = @queue
727
+ @unsubscribe_tools = Parse::Agent::Tools.subscribe do
728
+ queue << build_list_changed_event("notifications/tools/list_changed")
729
+ end
730
+ @unsubscribe_prompts = Parse::Agent::Prompts.subscribe do
731
+ queue << build_list_changed_event("notifications/prompts/list_changed")
732
+ end
733
+
734
+ @worker = Thread.new do
735
+ Thread.current[:parse_mcp_sse_worker] = true
736
+ started_at = Time.now
737
+ result = nil
738
+
739
+ begin
740
+ # Run the dispatcher in the background. Meanwhile emit heartbeats
741
+ # every @interval seconds until the call completes OR until a
742
+ # tool starts reporting its own progress (@tool_progress_reported).
743
+ #
744
+ # Cancellation note: if the consumer disconnects (close is called),
745
+ # the outer @worker is killed but dispatcher_thread is orphaned and
746
+ # runs to completion. A proper cancellation mechanism (e.g. passing
747
+ # a cancel token into MCPDispatcher) is a separate deferred item
748
+ # (see CHANGELOG / project plans).
749
+ #
750
+ # Each dispatcher_thread is tagged with :parse_mcp_dispatcher so
751
+ # operators can observe concurrency via
752
+ # Parse::Agent::MCPRackApp.active_dispatcher_count. Orphaned
753
+ # dispatchers (from client disconnects) are counted until they
754
+ # complete naturally. Forcible kill is intentionally not attempted
755
+ # here — killing threads inside MCPDispatcher.call risks leaving
756
+ # agent state corrupt. The max_concurrent_dispatchers: constructor
757
+ # option provides a concurrency cap that fires 503 before a new
758
+ # dispatcher is admitted.
759
+ dispatcher_thread = Thread.new do
760
+ Thread.current[:parse_mcp_dispatcher] = true
761
+ begin
762
+ # The block receives the SSEBody's progress callback so
763
+ # tools running inside MCPDispatcher.call can emit
764
+ # notifications/progress events without coupling to
765
+ # SSEBody internals.
766
+ result = @dispatcher_blk.call(@progress_callback)
767
+ rescue StandardError => e
768
+ # Log the unexpected failure (MCPDispatcher.call normally catches
769
+ # StandardError internally; anything reaching here is unusual).
770
+ line = "[Parse::Agent::MCPRackApp::SSEBody] Dispatcher error: #{e.class}: #{e.message}"
771
+ if @logger
772
+ @logger.warn(line)
773
+ else
774
+ warn line
775
+ end
776
+ result = { status: 200, body: build_error_envelope(e) }
777
+ end
778
+ end
779
+
780
+ while dispatcher_thread.alive?
781
+ dispatcher_thread.join(@interval)
782
+ # Skip the heartbeat when the tool has already reported
783
+ # work-unit progress on the same progressToken. Mixing
784
+ # elapsed-seconds heartbeats with work-unit values would
785
+ # break MCP's increasing-progress convention.
786
+ if dispatcher_thread.alive? && !@tool_progress_reported
787
+ elapsed = (Time.now - started_at).round(1)
788
+ @queue << build_progress_event(elapsed)
789
+ end
790
+ end
791
+
792
+ # Final response event followed by the done sentinel.
793
+ @queue << build_response_event(result[:body])
794
+ @queue << DONE
795
+ rescue StandardError => e
796
+ # Worker-level safety net for unexpected failures between the
797
+ # dispatcher loop and the queue writes.
798
+ line = "[Parse::Agent::MCPRackApp::SSEBody] Worker error: #{e.class}: #{e.message}"
799
+ if @logger
800
+ @logger.warn(line)
801
+ else
802
+ warn line
803
+ end
804
+ @queue << build_response_event(build_error_envelope(e))
805
+ @queue << DONE
806
+ ensure
807
+ # Belt-and-suspenders: guarantee the DONE sentinel is always
808
+ # pushed regardless of how the worker thread terminates (including
809
+ # Thread.kill / Interrupt / NoMemoryError which bypass rescue).
810
+ # If DONE was already pushed above the rescue nil is a no-op.
811
+ @queue << DONE rescue nil
812
+ end
813
+ end
814
+ end
815
+
816
+ # Format a time-based heartbeat `notifications/progress` SSE event.
817
+ #
818
+ # Heartbeats use a dedicated server-generated progressToken
819
+ # (`@heartbeat_token`), independent of the tool's progressToken.
820
+ # The MCP spec requires `progress` to increase monotonically
821
+ # per progressToken; mixing elapsed-seconds heartbeats with
822
+ # work-unit tool reports on the same token would break that.
823
+ # The `total` field is omitted (rather than nil) so the wire
824
+ # shape matches the spec's optional-field convention.
825
+ #
826
+ # @param elapsed [Float] seconds elapsed since the stream started.
827
+ # @return [String] SSE event string (includes trailing blank line).
828
+ def build_progress_event(elapsed)
829
+ data = JSON.generate({
830
+ "jsonrpc" => "2.0",
831
+ "method" => "notifications/progress",
832
+ "params" => {
833
+ "progressToken" => @heartbeat_token,
834
+ "progress" => elapsed,
835
+ },
836
+ })
837
+ "event: progress\ndata: #{data}\n\n"
838
+ end
839
+
840
+ # Format a `notifications/tools/list_changed` or
841
+ # `notifications/prompts/list_changed` SSE event. Both
842
+ # notifications have no `params` — the wire shape is just the
843
+ # JSON-RPC envelope with `method` set. SSE event name is
844
+ # "message" since this is not a progress notification (the
845
+ # progress event name is reserved for progress notifications).
846
+ #
847
+ # @param method [String] full MCP method string.
848
+ # @return [String] SSE event string (includes trailing blank line).
849
+ def build_list_changed_event(method)
850
+ data = JSON.generate({
851
+ "jsonrpc" => "2.0",
852
+ "method" => method,
853
+ })
854
+ "event: message\ndata: #{data}\n\n"
855
+ end
856
+
857
+ # Format a tool-internal `notifications/progress` SSE event.
858
+ #
859
+ # The `message` field requires MCP protocol version 2025-03-26 or
860
+ # later. The dispatcher advertises 2025-06-18, so this is safe for
861
+ # current clients. The field is omitted from the wire when nil.
862
+ #
863
+ # @param progress [Numeric] tool-reported progress value.
864
+ # @param total [Numeric, nil] tool-reported total, or nil.
865
+ # @param message [String, nil] optional status string, or nil.
866
+ # @return [String] SSE event string (includes trailing blank line).
867
+ def build_tool_progress_event(progress, total, message)
868
+ params = {
869
+ "progressToken" => @progress_token,
870
+ "progress" => progress,
871
+ }
872
+ params["total"] = total unless total.nil?
873
+ params["message"] = message if message
874
+ data = JSON.generate({
875
+ "jsonrpc" => "2.0",
876
+ "method" => "notifications/progress",
877
+ "params" => params,
878
+ })
879
+ "event: progress\ndata: #{data}\n\n"
880
+ end
881
+
882
+ # Build the callback the dispatcher block passes into
883
+ # `MCPDispatcher.call(progress_callback:)`. The callback pushes a
884
+ # tool-progress SSE event to the worker's queue and marks the
885
+ # tool-reporting flag so subsequent time-based heartbeats are
886
+ # suppressed. Exceptions raised by the JSON encoder or the queue
887
+ # are logged via the injected logger and swallowed — a malformed
888
+ # progress report must never abort the underlying tool.
889
+ #
890
+ # The returned Proc is thread-safe by virtue of Queue#<< being
891
+ # thread-safe. The flag write race documented in {#initialize}
892
+ # has a worst-case impact of one extra heartbeat.
893
+ def build_progress_callback
894
+ logger = @logger
895
+ lambda do |progress:, total: nil, message: nil|
896
+ begin
897
+ @tool_progress_reported = true
898
+ @queue << build_tool_progress_event(progress, total, message)
899
+ rescue StandardError => e
900
+ line = "[Parse::Agent::MCPRackApp::SSEBody] progress_callback error: #{e.class}: #{e.message}"
901
+ if logger
902
+ logger.warn(line)
903
+ else
904
+ warn line
905
+ end
906
+ end
907
+ nil
908
+ end
909
+ end
910
+
911
+ # Format the final `response` SSE event.
912
+ #
913
+ # @param body [Hash] JSON-RPC response envelope.
914
+ # @return [String] SSE event string (includes trailing blank line).
915
+ def build_response_event(body)
916
+ "event: response\ndata: #{JSON.generate(body)}\n\n"
917
+ end
918
+
919
+ # Build an internal-error JSON-RPC envelope (id may be nil at this layer).
920
+ def build_error_envelope(error)
921
+ {
922
+ "jsonrpc" => "2.0",
923
+ "id" => @req_id,
924
+ "error" => { "code" => -32_603, "message" => "Internal error" },
925
+ }
926
+ end
927
+ end
928
+
929
+ # ---------------------------------------------------------------------------
930
+ # Cancellation registry
931
+ # ---------------------------------------------------------------------------
932
+
933
+ # Per-app store of in-flight cancellable requests. Lookups for
934
+ # cancellation are keyed by `[correlation_id, request_id]`, but
935
+ # every {#register} returns an opaque entry-id token that
936
+ # uniquely identifies the registration. {#deregister} requires
937
+ # that entry-id and removes the matching token only when it
938
+ # still owns the slot — so a second registration under the same
939
+ # `(correlation_id, request_id)` key cannot cause the first
940
+ # registration's `on_close` to evict the wrong token.
941
+ #
942
+ # SSEBody registers an entry before spawning its dispatcher_thread
943
+ # and deregisters via the MCPRackApp-supplied on_close hook. A
944
+ # `notifications/cancelled` POST calls {#cancel} to trip the
945
+ # matching CancellationToken.
946
+ #
947
+ # Identity binding: cancellation requires the cancelling request's
948
+ # `X-MCP-Session-Id` (sanitized into `agent.correlation_id`) to
949
+ # match the original request's. This prevents an attacker who
950
+ # guesses sequential JSON-RPC request ids from cancelling other
951
+ # clients' in-flight requests. A registration with a nil
952
+ # correlation_id is dropped silently (cancellation is disabled for
953
+ # the request).
954
+ #
955
+ # Scope: per MCPRackApp instance. Cancellation does NOT span
956
+ # multiple mount points within a process, nor multiple processes
957
+ # in a clustered deployment.
958
+ #
959
+ # @api private
960
+ class CancellationRegistry
961
+ def initialize
962
+ @entries = {}
963
+ @mutex = Mutex.new
964
+ end
965
+
966
+ # Register a cancellation token for the given session and
967
+ # request id pair. Returns an opaque entry-id that the caller
968
+ # must pass to {#deregister} to release the slot. If multiple
969
+ # registrations land on the same key (legitimate id-reuse by
970
+ # the same session, or a request retry), only the latest
971
+ # registration is reachable for {#cancel}; older entries can
972
+ # still be safely released via their entry-id even though they
973
+ # no longer "own" the slot.
974
+ #
975
+ # @param correlation_id [String, nil] session identity (nil
976
+ # disables cancellation for the registration).
977
+ # @param request_id [Object] JSON-RPC request id (any
978
+ # JSON-encodable value).
979
+ # @param token [Parse::Agent::CancellationToken]
980
+ # @return [String, nil] opaque entry-id, or nil when
981
+ # registration was refused (no correlation_id).
982
+ def register(correlation_id, request_id, token)
983
+ return nil if correlation_id.nil? || correlation_id.to_s.empty?
984
+ entry_id = SecureRandom.uuid
985
+ @mutex.synchronize do
986
+ @entries[[correlation_id, request_id]] = [entry_id, token]
987
+ end
988
+ entry_id
989
+ end
990
+
991
+ # Release a previously-registered entry. Removes the slot only
992
+ # when the current owner matches the passed entry-id, so a
993
+ # stale on_close from a request whose slot was overwritten by
994
+ # a sibling registration cannot evict the sibling's token.
995
+ # Idempotent.
996
+ #
997
+ # @return [Boolean] true if this call removed the entry.
998
+ def deregister(correlation_id, request_id, entry_id)
999
+ return false if correlation_id.nil? || correlation_id.to_s.empty?
1000
+ return false if entry_id.nil?
1001
+ @mutex.synchronize do
1002
+ current = @entries[[correlation_id, request_id]]
1003
+ if current && current[0] == entry_id
1004
+ @entries.delete([correlation_id, request_id])
1005
+ true
1006
+ else
1007
+ false
1008
+ end
1009
+ end
1010
+ end
1011
+
1012
+ # Trip the matching token. Silent no-op when the entry is
1013
+ # missing — by design, to avoid a probe oracle.
1014
+ #
1015
+ # @return [Boolean] true if a matching token was tripped.
1016
+ def cancel(correlation_id, request_id, reason: :notifications_cancelled)
1017
+ return false if correlation_id.nil? || correlation_id.to_s.empty?
1018
+ entry = @mutex.synchronize { @entries[[correlation_id, request_id]] }
1019
+ return false unless entry
1020
+ entry[1].cancel!(reason: reason)
1021
+ true
1022
+ end
1023
+
1024
+ # @return [Integer] number of currently-registered tokens. Used
1025
+ # by tests and operator dashboards.
1026
+ def size
1027
+ @mutex.synchronize { @entries.size }
1028
+ end
1029
+ end
1030
+
1031
+ # ---------------------------------------------------------------------------
1032
+ # Response-header helpers
1033
+ # ---------------------------------------------------------------------------
1034
+
1035
+ # Return a per-response copy of the JSON content-type header hash. Always
1036
+ # returns a fresh, unfrozen hash so Rack middleware that decorates
1037
+ # response headers (Sinatra's xss_header, json_csrf, common_logger,
1038
+ # rack-deflater, etc.) can mutate the result without FrozenError, and
1039
+ # so that cross-request mutation cannot leak through a shared singleton.
1040
+ def json_headers
1041
+ JSON_CONTENT_TYPE.dup
1042
+ end
1043
+
1044
+ # Return a per-response copy of the SSE header hash. See {#json_headers}.
1045
+ def sse_headers
1046
+ SSE_HEADERS.dup
1047
+ end
1048
+
1049
+ # ---------------------------------------------------------------------------
1050
+ # JSON-RPC envelope helpers
1051
+ # ---------------------------------------------------------------------------
1052
+
1053
+ # Build a sanitized JSON-RPC 2.0 error envelope.
1054
+ #
1055
+ # The id defaults to null because most transport-level errors occur before
1056
+ # the body has been parsed. Pass `id:` explicitly when the request id is
1057
+ # available (e.g. the 503 server-busy response returned from serve_sse
1058
+ # after successful body parsing).
1059
+ #
1060
+ # @param code [Integer] JSON-RPC error code.
1061
+ # @param message [String] sanitized error message.
1062
+ # @param id [Object] JSON-RPC request id; defaults to nil.
1063
+ # @return [String] JSON string.
1064
+ def json_rpc_error(code, message, id: nil)
1065
+ JSON.generate({
1066
+ "jsonrpc" => "2.0",
1067
+ "id" => id,
1068
+ "error" => { "code" => code, "message" => message },
1069
+ })
1070
+ end
1071
+
1072
+ # Fixed 401 body — no exception details leak to the caller.
1073
+ def unauthorized_body
1074
+ JSON.generate({
1075
+ "jsonrpc" => "2.0",
1076
+ "id" => nil,
1077
+ "error" => { "code" => -32_001, "message" => "Unauthorized" },
1078
+ })
1079
+ end
1080
+
1081
+ # Normalize the allowed-origins kwarg into a frozen Array of
1082
+ # downcased entries. Returns nil when the caller passed nil or an
1083
+ # empty array (no check configured). Each entry retains its
1084
+ # leading-`.` form for subdomain wildcards.
1085
+ def normalize_allowed_origins(value)
1086
+ return nil if value.nil?
1087
+ arr = Array(value).map { |v| v.to_s.strip.downcase }.reject(&:empty?)
1088
+ arr.empty? ? nil : arr.freeze
1089
+ end
1090
+
1091
+ # Normalize the `require_custom_header:` kwarg into a
1092
+ # `[env_key, expected_value]` pair, or nil when no check is
1093
+ # configured. Accepts:
1094
+ # - String header name ("X-MCP-Client") → require presence,
1095
+ # any non-empty value passes.
1096
+ # - Hash { "X-MCP-Client" => "expected-value" } → require
1097
+ # presence AND exact match.
1098
+ def normalize_required_custom_header(value)
1099
+ return nil if value.nil?
1100
+ case value
1101
+ when String
1102
+ name = value.to_s.strip
1103
+ return nil if name.empty?
1104
+ [header_env_key(name), nil]
1105
+ when Hash
1106
+ return nil if value.empty?
1107
+ name, expected = value.first
1108
+ name = name.to_s.strip
1109
+ return nil if name.empty?
1110
+ [header_env_key(name), expected.to_s]
1111
+ else
1112
+ raise ArgumentError,
1113
+ "require_custom_header must be a String header name or a Hash { name => expected_value }, " \
1114
+ "got #{value.class}"
1115
+ end
1116
+ end
1117
+
1118
+ # Map an HTTP header name to its Rack env key.
1119
+ def header_env_key(name)
1120
+ "HTTP_#{name.upcase.tr("-", "_")}"
1121
+ end
1122
+
1123
+ # Match an incoming `Origin` header value against
1124
+ # `@allowed_origins`. Comparison is case-insensitive on host and
1125
+ # scheme. Wildcard via leading `.` matches subdomains:
1126
+ # `.example.com` matches `app.example.com` and `example.com`.
1127
+ def origin_allowed?(origin)
1128
+ return false unless @allowed_origins
1129
+ normalized = origin.downcase
1130
+ @allowed_origins.any? do |entry|
1131
+ if entry.start_with?(".")
1132
+ # Strip scheme to compare host
1133
+ origin_host = normalized.sub(%r{\Ahttps?://}, "")
1134
+ entry_bare = entry[1..]
1135
+ origin_host == entry_bare || origin_host.end_with?(".#{entry_bare}") || origin_host.end_with?(entry)
1136
+ else
1137
+ normalized == entry
1138
+ end
1139
+ end
1140
+ end
1141
+ end
1142
+ end
1143
+ end