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.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- 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
|