parse-stack-next 5.2.1 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.bundle/config +1 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +616 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +296 -3
- data/Rakefile +243 -41
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +38 -0
- data/docs/mcp_guide.md +119 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +174 -9
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +200 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/pluralized_aliases.rb +30 -0
- data/lib/parse/model/core/properties.rb +27 -0
- data/lib/parse/model/core/querying.rb +73 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +173 -1
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +399 -11
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- metadata +16 -1
data/Rakefile
CHANGED
|
@@ -35,6 +35,99 @@ def mcp_credentials_or_abort!
|
|
|
35
35
|
[server_url, app_id, rest_api_key, master_key]
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Resolve the identity for `rake client:console`. Returns a session-token String,
|
|
39
|
+
# or nil to mean "anonymous" (no token, no master). Order: PARSE_SESSION_TOKEN,
|
|
40
|
+
# PARSE_CLIENT_ANONYMOUS=true, PARSE_LOGIN_USER (+PARSE_LOGIN_PASSWORD), else
|
|
41
|
+
# prompt for login / token / anon. The login path uses the configured default
|
|
42
|
+
# client (the master client set up just before this is called).
|
|
43
|
+
def client_console_token!
|
|
44
|
+
token = ENV["PARSE_SESSION_TOKEN"].to_s.strip
|
|
45
|
+
return token unless token.empty?
|
|
46
|
+
return nil if ENV["PARSE_CLIENT_ANONYMOUS"].to_s == "true"
|
|
47
|
+
|
|
48
|
+
user = ENV["PARSE_LOGIN_USER"].to_s.strip
|
|
49
|
+
if user.empty?
|
|
50
|
+
print "Identity? [login/token/anon] (login): "
|
|
51
|
+
case $stdin.gets.to_s.strip.downcase
|
|
52
|
+
when "anon", "anonymous" then return nil
|
|
53
|
+
when "token"
|
|
54
|
+
print "Session token (r:...): "
|
|
55
|
+
t = $stdin.gets.to_s.strip
|
|
56
|
+
return t.empty? ? nil : t
|
|
57
|
+
else
|
|
58
|
+
print "Username (blank for anonymous): "
|
|
59
|
+
user = $stdin.gets.to_s.strip
|
|
60
|
+
return nil if user.empty?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
pwd = ENV["PARSE_LOGIN_PASSWORD"].to_s
|
|
65
|
+
if pwd.empty?
|
|
66
|
+
begin
|
|
67
|
+
require "io/console"
|
|
68
|
+
print "Password for #{user}: "
|
|
69
|
+
pwd = $stdin.noecho(&:gets).to_s
|
|
70
|
+
puts
|
|
71
|
+
rescue LoadError, IOError, SystemCallError
|
|
72
|
+
# io/console missing, or stdin is not a TTY (piped input raises
|
|
73
|
+
# Errno::ENOTTY, a SystemCallError — not an IOError). Fall back to a
|
|
74
|
+
# plain read; warn that it will echo.
|
|
75
|
+
warn "[client:console] WARNING: cannot disable echo; password will be visible."
|
|
76
|
+
print "Password for #{user}: "
|
|
77
|
+
pwd = $stdin.gets.to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
u = console_login_with_optional_mfa(user, pwd.chomp)
|
|
81
|
+
abort "[client:console] login failed for #{user.inspect}" if u.nil? || u.session_token.to_s.empty?
|
|
82
|
+
puts "Logged in as #{u.username} (#{u.id})."
|
|
83
|
+
u.session_token
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Log `user` in, transparently handling an MFA-enrolled account. If the server
|
|
87
|
+
# reports that additional MFA auth is required, prompt for a TOTP / recovery
|
|
88
|
+
# code (or read +PARSE_LOGIN_MFA+ for non-interactive use) and retry via
|
|
89
|
+
# {Parse::User.login_with_mfa}. Returns a logged-in {Parse::User}, or nil when
|
|
90
|
+
# the credentials themselves are rejected (so the caller's "login failed" abort
|
|
91
|
+
# still fires for a bad password).
|
|
92
|
+
def console_login_with_optional_mfa(user, pwd)
|
|
93
|
+
# Parse Server signals "this account needs an MFA token" two ways depending on
|
|
94
|
+
# the error code path: a returned error response ("Missing additional
|
|
95
|
+
# authData ...") or a raised Parse::Error for the OTHER_CAUSE (code <= 100)
|
|
96
|
+
# variant. Treat both as "prompt for MFA"; anything else is a real credential
|
|
97
|
+
# failure and must NOT trigger an MFA prompt.
|
|
98
|
+
mfa_indicator = /additional\s+authData|missing.*mfa|\bMFA\b/i
|
|
99
|
+
begin
|
|
100
|
+
response = Parse.client.login(user, pwd)
|
|
101
|
+
if response.success?
|
|
102
|
+
return Parse::User.with_authdata_trust { Parse::User.build(response.result) }
|
|
103
|
+
end
|
|
104
|
+
return nil unless response.error.to_s.match?(mfa_indicator)
|
|
105
|
+
rescue Parse::Error, Parse::Client::ResponseError => e
|
|
106
|
+
raise unless e.message.to_s.match?(mfa_indicator)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
token = ENV["PARSE_LOGIN_MFA"].to_s.strip
|
|
110
|
+
if token.empty?
|
|
111
|
+
print "MFA token (authenticator code or recovery code): "
|
|
112
|
+
token = $stdin.gets.to_s.strip
|
|
113
|
+
end
|
|
114
|
+
abort "[client:console] MFA token required for #{user.inspect}" if token.empty?
|
|
115
|
+
|
|
116
|
+
# A wrong/expired token can surface either as Parse::MFA::VerificationError or,
|
|
117
|
+
# depending on the server error code path, as a generic Parse::Error (e.g.
|
|
118
|
+
# ServiceUnavailableError for the OTHER_CAUSE code) or a nil return. Since a
|
|
119
|
+
# token was supplied here, treat any failure as an MFA verification failure
|
|
120
|
+
# and abort cleanly rather than letting an unhandled exception escape.
|
|
121
|
+
result =
|
|
122
|
+
begin
|
|
123
|
+
Parse::User.login_with_mfa(user, pwd, token)
|
|
124
|
+
rescue Parse::MFA::VerificationError, Parse::Error => e
|
|
125
|
+
abort "[client:console] MFA verification failed for #{user.inspect}: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
abort "[client:console] MFA verification failed for #{user.inspect}" if result.nil?
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
38
131
|
# Default test task runs all tests with Docker enabled.
|
|
39
132
|
#
|
|
40
133
|
# `*disruptive*` tests are EXCLUDED here: they stop/restart the shared
|
|
@@ -48,55 +141,96 @@ Rake::TestTask.new do |t|
|
|
|
48
141
|
t.verbose = true
|
|
49
142
|
end
|
|
50
143
|
|
|
144
|
+
# Shared runner for the file-per-process test tasks. Each test file runs in its
|
|
145
|
+
# own Ruby process (isolation against the shared Parse Server); output streams
|
|
146
|
+
# live, and — for trackability — a PASS/FAIL + duration line per file is printed
|
|
147
|
+
# to STDOUT *and* appended to a progress log you can `tail -f` from another
|
|
148
|
+
# shell while the run is in flight (works even when the run is backgrounded or
|
|
149
|
+
# piped, where STDOUT would otherwise buffer until completion).
|
|
150
|
+
#
|
|
151
|
+
# Env knobs:
|
|
152
|
+
# TEST_PATTERN=<substr> run only files whose path includes <substr>
|
|
153
|
+
# (e.g. TEST_PATTERN=webhook)
|
|
154
|
+
# CONTINUE_ON_FAILURE=false stop at the first failing file
|
|
155
|
+
# (default: run them all, then list every failure)
|
|
156
|
+
#
|
|
157
|
+
# Exits non-zero if any file failed.
|
|
158
|
+
def run_test_files!(label, files, log:)
|
|
159
|
+
$stdout.sync = true
|
|
160
|
+
require "fileutils"
|
|
161
|
+
if (pattern = ENV["TEST_PATTERN"].to_s).length.positive?
|
|
162
|
+
files = files.select { |f| f.include?(pattern) }
|
|
163
|
+
puts "TEST_PATTERN=#{pattern} -> #{files.length} matching file(s)"
|
|
164
|
+
end
|
|
165
|
+
continue = ENV.fetch("CONTINUE_ON_FAILURE", "true") != "false"
|
|
166
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
167
|
+
total = files.length
|
|
168
|
+
started = Time.now
|
|
169
|
+
results = []
|
|
170
|
+
File.write(log, "#{label}: #{total} files, started #{started}\n")
|
|
171
|
+
puts "\n>> #{label}: #{total} files (progress log: #{log})"
|
|
172
|
+
|
|
173
|
+
files.each_with_index do |file, i|
|
|
174
|
+
n = i + 1
|
|
175
|
+
puts "\n" + "=" * 80
|
|
176
|
+
puts "[#{n}/#{total}] #{file}"
|
|
177
|
+
puts "=" * 80
|
|
178
|
+
t0 = Time.now
|
|
179
|
+
# Always go through `bundle exec` so the locked gem versions win. With a
|
|
180
|
+
# bare `ruby`, RubyGems activates the newest installed minitest (6.0.x),
|
|
181
|
+
# which dropped the bundled `minitest/mock`; the standalone `minitest-mock`
|
|
182
|
+
# gem then can't co-activate and `test_helper.rb` fails to load every file.
|
|
183
|
+
ok = system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}")
|
|
184
|
+
dt = Time.now - t0
|
|
185
|
+
results << [file, ok, dt]
|
|
186
|
+
summary = format("[%d/%d] %-4s %7.1fs %s", n, total, ok ? "PASS" : "FAIL", dt, file)
|
|
187
|
+
puts summary
|
|
188
|
+
File.open(log, "a") { |f| f.puts summary }
|
|
189
|
+
if !ok && !continue
|
|
190
|
+
File.open(log, "a") { |f| f.puts "STOPPED at first failure (CONTINUE_ON_FAILURE=false)" }
|
|
191
|
+
break
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
elapsed = Time.now - started
|
|
196
|
+
passed = results.count { |_, ok, _| ok }
|
|
197
|
+
failed = results.reject { |_, ok, _| ok }
|
|
198
|
+
footer = format("%s: %d/%d passed in %.1fs (%.1f min)",
|
|
199
|
+
label, passed, results.length, elapsed, elapsed / 60.0)
|
|
200
|
+
puts "\n" + "=" * 80
|
|
201
|
+
puts footer
|
|
202
|
+
unless failed.empty?
|
|
203
|
+
puts "Failed (#{failed.length}):"
|
|
204
|
+
failed.each { |f, _, d| puts format(" FAIL %7.1fs %s", d, f) }
|
|
205
|
+
end
|
|
206
|
+
puts "=" * 80
|
|
207
|
+
File.open(log, "a") do |f|
|
|
208
|
+
f.puts footer
|
|
209
|
+
failed.each { |ff, _, d| f.puts format(" FAIL %7.1fs %s", d, ff) }
|
|
210
|
+
end
|
|
211
|
+
exit(1) unless failed.empty?
|
|
212
|
+
end
|
|
213
|
+
|
|
51
214
|
# Integration tests require Docker
|
|
52
215
|
namespace :test do
|
|
53
|
-
desc "Run all integration tests (requires Docker)"
|
|
216
|
+
desc "Run all integration tests (requires Docker). " \
|
|
217
|
+
"Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
|
|
54
218
|
task :integration do
|
|
55
219
|
# Disruptive tests (server stop/restart) are run separately via
|
|
56
220
|
# `test:integration:disruptive` so they never interleave with — and
|
|
57
221
|
# flake — the rest of the integration suite against the shared server.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
puts "Running #{integration_files.length} integration test files..."
|
|
62
|
-
integration_files.each_with_index do |file, index|
|
|
63
|
-
puts "Running integration test #{index + 1}/#{integration_files.length}: #{file}"
|
|
64
|
-
|
|
65
|
-
# 10: docker integration test fails for cloud functions
|
|
66
|
-
skip_till = 0
|
|
67
|
-
if (index + 1) <= skip_till
|
|
68
|
-
puts "Skipping test #{index + 1} as per configuration\n"
|
|
69
|
-
next
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
puts "\n" + "="*80
|
|
73
|
-
puts "Running: #{file}"
|
|
74
|
-
puts "="*80
|
|
75
|
-
system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || exit(1)
|
|
76
|
-
end
|
|
77
|
-
puts "\n✅ All integration tests completed successfully!"
|
|
222
|
+
files = FileList["test/lib/**/*integration_test.rb"]
|
|
223
|
+
.exclude("test/lib/**/*disruptive*")
|
|
224
|
+
run_test_files!("Integration tests", files, log: "tmp/integration-progress.log")
|
|
78
225
|
end
|
|
79
226
|
|
|
80
|
-
desc "Run unit tests only (no Docker required)"
|
|
227
|
+
desc "Run unit tests only (no Docker required). " \
|
|
228
|
+
"Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
|
|
81
229
|
task :unit do
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
puts "Running #{unit_files.length} unit test files (no Docker)..."
|
|
87
|
-
unit_files.each_with_index do |file, index|
|
|
88
|
-
puts "Running unit test #{index + 1}/#{unit_files.length}: #{file}"
|
|
89
|
-
|
|
90
|
-
# 73 is problematic Testing Contains and Nin with Parse Objects with contains and nin
|
|
91
|
-
skip_till = 0
|
|
92
|
-
if (index + 1) <= skip_till
|
|
93
|
-
puts "Skipping test #{index + 1} as per configuration"
|
|
94
|
-
next
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || exit(1)
|
|
98
|
-
end
|
|
99
|
-
puts "\n✅ All unit tests completed successfully!"
|
|
230
|
+
files = FileList["test/lib/**/*_test.rb"]
|
|
231
|
+
.exclude("test/lib/**/*integration_test.rb")
|
|
232
|
+
.exclude("test/lib/**/*disruptive*")
|
|
233
|
+
run_test_files!("Unit tests", files, log: "tmp/unit-progress.log")
|
|
100
234
|
end
|
|
101
235
|
|
|
102
236
|
namespace :integration do
|
|
@@ -118,7 +252,7 @@ namespace :test do
|
|
|
118
252
|
puts "=" * 80
|
|
119
253
|
# Each file runs in its own process so a server outage in one cannot
|
|
120
254
|
# bleed into the next.
|
|
121
|
-
system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || begin
|
|
255
|
+
system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}") || begin
|
|
122
256
|
# A disruptive test may have left the server down on failure; bring
|
|
123
257
|
# it back so a follow-up run / other tasks start from a clean state.
|
|
124
258
|
system("docker start #{ENV["PSNEXT_PREFIX"] || "psnext-it"}-server", out: IO::NULL, err: IO::NULL)
|
|
@@ -602,6 +736,74 @@ namespace :mcp do
|
|
|
602
736
|
end
|
|
603
737
|
end
|
|
604
738
|
|
|
739
|
+
# rake client:console — IRB whose DEFAULT client is a non-master client bound to
|
|
740
|
+
# a user's session, so every model query runs as that user (ACL/CLP enforced).
|
|
741
|
+
# Identity: PARSE_SESSION_TOKEN, or PARSE_LOGIN_USER/PARSE_LOGIN_PASSWORD, else
|
|
742
|
+
# prompt. Connection env matches the other tasks; the master key is used only to
|
|
743
|
+
# log in. Helpers in the REPL: client, whoami, as_master { … }.
|
|
744
|
+
namespace :client do
|
|
745
|
+
desc "Interactive console authenticated as a Parse user (session token or login), not master"
|
|
746
|
+
task :console do
|
|
747
|
+
require "irb"
|
|
748
|
+
begin; require "dotenv/load"; rescue LoadError; end
|
|
749
|
+
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
|
|
750
|
+
require "parse-stack"
|
|
751
|
+
|
|
752
|
+
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
|
|
753
|
+
Parse.setup(server_url: server_url, application_id: app_id, api_key: rest_api_key, master_key: master_key)
|
|
754
|
+
master_client = Parse.client
|
|
755
|
+
token = client_console_token! # String, or nil for anonymous
|
|
756
|
+
|
|
757
|
+
# Models memoize their resolved client at the class level (`@client ||=`),
|
|
758
|
+
# so swapping clients[:default] alone is NOT enough — every swap must also
|
|
759
|
+
# drop those cached ivars or already-touched classes keep the old client.
|
|
760
|
+
reset_client_caches = lambda do
|
|
761
|
+
next unless defined?(Parse::Object)
|
|
762
|
+
[Parse::Object, *Parse::Object.descendants, Parse::Query].each do |k|
|
|
763
|
+
k.remove_instance_variable(:@client) if k.instance_variable_defined?(:@client)
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Make a non-master client (session-bound, or anonymous) the default so
|
|
768
|
+
# every model query runs as the user.
|
|
769
|
+
user_client = token ? master_client.become(token) : master_client.anonymous
|
|
770
|
+
Parse::Client.clients[:default] = user_client
|
|
771
|
+
reset_client_caches.call
|
|
772
|
+
|
|
773
|
+
Object.send(:define_method, :client) { user_client }
|
|
774
|
+
# Redacted: GET /users/me returns a Parse::User carrying the live
|
|
775
|
+
# sessionToken, and Parse::Object has no redacted #inspect, so returning the
|
|
776
|
+
# raw object would print the token in the REPL. Hand back a safe summary.
|
|
777
|
+
Object.send(:define_method, :whoami) do
|
|
778
|
+
next "anonymous (no session)" unless token
|
|
779
|
+
r = user_client.current_user(token)
|
|
780
|
+
next "whoami failed: #{r.error}" unless r.success?
|
|
781
|
+
u = r.result
|
|
782
|
+
{ "username" => u["username"], "objectId" => u["objectId"], "session_token" => "[FILTERED]" }
|
|
783
|
+
rescue StandardError => e
|
|
784
|
+
"whoami failed: #{e.message}"
|
|
785
|
+
end
|
|
786
|
+
# Escalate to the master key for the block, then restore the user client.
|
|
787
|
+
# Both swaps must reset the class client caches (see reset_client_caches),
|
|
788
|
+
# otherwise a class touched inside the block keeps master afterward, or a
|
|
789
|
+
# previously-touched class never escalates. Already-instantiated Parse::Query
|
|
790
|
+
# objects held in REPL locals keep their own client and are not reset.
|
|
791
|
+
Object.send(:define_method, :as_master) do |&blk|
|
|
792
|
+
Parse::Client.clients[:default] = master_client
|
|
793
|
+
reset_client_caches.call
|
|
794
|
+
blk.call
|
|
795
|
+
ensure
|
|
796
|
+
Parse::Client.clients[:default] = user_client
|
|
797
|
+
reset_client_caches.call
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
mode = token ? "a USER" : "ANONYMOUS"
|
|
801
|
+
puts "parse-stack-next client console — as #{mode} (no master key). Helpers: client, whoami, as_master { }."
|
|
802
|
+
ARGV.clear
|
|
803
|
+
IRB.start
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
|
|
605
807
|
desc "List undocumented methods"
|
|
606
808
|
task "yard:stats" do
|
|
607
809
|
exec "yard stats --list-undoc"
|
|
@@ -372,6 +372,10 @@ embed-time chunking), use one of these patterns:
|
|
|
372
372
|
|
|
373
373
|
## Retrieval (RAG)
|
|
374
374
|
|
|
375
|
+
> For an end-to-end runnable script — managed `embed`, `agent_searchable`,
|
|
376
|
+
> `semantic_search`, and an OpenAI/Anthropic generation add-in — see
|
|
377
|
+
> [`examples/rag_chatbot.rb`](../examples/rag_chatbot.rb).
|
|
378
|
+
|
|
375
379
|
`Parse::Retrieval` (`Parse::RAG` is an alias) sits on top of
|
|
376
380
|
`find_similar`. `Parse::Retrieval.retrieve` embeds a natural-language
|
|
377
381
|
query, runs Atlas `$vectorSearch` through `find_similar` (so ACL/CLP are
|
|
@@ -395,8 +399,88 @@ chunks = Parse::Retrieval.retrieve(
|
|
|
395
399
|
# => Array<Parse::Retrieval::Chunk> — { id, score, content, source, metadata }
|
|
396
400
|
```
|
|
397
401
|
|
|
398
|
-
`
|
|
399
|
-
|
|
402
|
+
`retrieve` also accepts `hybrid:` (fuse a lexical branch with the vector
|
|
403
|
+
branch — see [Hybrid search](#hybrid-search-vector--lexical) below) and
|
|
404
|
+
`rerank:` (reorder retrieved documents with a cross-encoder before
|
|
405
|
+
chunking — see [Reranking](#reranking)). Both were reserved in earlier
|
|
406
|
+
releases and now ship in 5.4.0.
|
|
407
|
+
|
|
408
|
+
### Hybrid search (vector + lexical)
|
|
409
|
+
|
|
410
|
+
`Class.hybrid_search` runs a lexical Atlas Search (`$search`) branch and a
|
|
411
|
+
`$vectorSearch` branch as **two independent aggregations**, then fuses
|
|
412
|
+
their ranked results with reciprocal-rank fusion (RRF). Two aggregations
|
|
413
|
+
(not a single `$facet`) is mandatory: `$vectorSearch` is prohibited inside
|
|
414
|
+
`$facet` / `$lookup` / `$unionWith` and must be stage 0 of its pipeline.
|
|
415
|
+
Each branch enforces ACL/CLP/`protectedFields` independently before
|
|
416
|
+
fusion (via `Parse::AtlasSearch.search` and `Parse::VectorSearch.search`),
|
|
417
|
+
so the fused rows are already access-filtered — there is no separate
|
|
418
|
+
hydration fetch.
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
hits = Article.hybrid_search(
|
|
422
|
+
text: "how do I reset my password", # embedded for the vector branch;
|
|
423
|
+
# also the default lexical query
|
|
424
|
+
lexical: { index: "article_search", fields: %w[title body] },
|
|
425
|
+
vector: { index: "article_embedding_idx", num_candidates: 200 },
|
|
426
|
+
k: 20,
|
|
427
|
+
fusion: { k_constant: 60, weights: { lexical: 0.4, vector: 0.6 } },
|
|
428
|
+
session_token: user.session_token, # ACL scope, applied to BOTH branches
|
|
429
|
+
)
|
|
430
|
+
# => Array<Parse::Object>; each carries #hybrid_score, #hybrid_ranks,
|
|
431
|
+
# and #vector_score / #search_score when that branch contributed.
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**RRF math.** `fused_score(d) = Σ_b weight_b / (k_constant + rank_b(d))`,
|
|
435
|
+
where `rank_b(d)` is the document's 1-based rank in branch `b`. A larger
|
|
436
|
+
`k_constant` (default 60) flattens the contribution curve. `weights`
|
|
437
|
+
defaults to 1.0 per branch. `Parse::VectorSearch::Hybrid.rrf` exposes the
|
|
438
|
+
pure fusion if you want to fuse pre-fetched ranked lists yourself.
|
|
439
|
+
|
|
440
|
+
**Native `$rankFusion` (Atlas 8.0+).**
|
|
441
|
+
`Parse::VectorSearch::Hybrid.rank_fusion_supported?(collection)` detects
|
|
442
|
+
the native server-side fusion stage via a cached behavioural probe (1-hour
|
|
443
|
+
TTL — not version-string parsing). Native execution is **opt-in**
|
|
444
|
+
(`fusion: { method: :rrf_native }`) and falls back to the client-side path
|
|
445
|
+
when the cluster does not support it; the default `:rrf` always fuses
|
|
446
|
+
client-side, which is the fully-enforced, deterministic path. `$rankFusion`
|
|
447
|
+
is admitted to `PipelineSecurity::ALLOWED_STAGES` for the native path.
|
|
448
|
+
|
|
449
|
+
`Parse::Retrieval.retrieve(hybrid: true, ...)` routes through
|
|
450
|
+
`hybrid_search` and chunks the fused results; pass `hybrid: { lexical:,
|
|
451
|
+
vector:, fusion: }` to configure the branches. Tenant scope is folded into
|
|
452
|
+
**both** branches (the vector Atlas pre-filter and the lexical
|
|
453
|
+
post-`$search` `$match`) so neither leaks cross-tenant document existence.
|
|
454
|
+
|
|
455
|
+
### Reranking
|
|
456
|
+
|
|
457
|
+
A reranker reorders retrieved documents by a cross-encoder relevance score
|
|
458
|
+
**before** chunking. Pass any object answering
|
|
459
|
+
`#rerank(query:, documents:, top_n:)` — typically a
|
|
460
|
+
`Parse::Retrieval::Reranker::Base` subclass:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
reranker = Parse::Retrieval::Reranker::Cohere.new(
|
|
464
|
+
api_key: ENV.fetch("COHERE_API_KEY"), model: "rerank-v3.5",
|
|
465
|
+
)
|
|
466
|
+
chunks = Parse::Retrieval.retrieve(
|
|
467
|
+
query: "reset my password", klass: Article, k: 30,
|
|
468
|
+
rerank: reranker, rerank_top_n: 5, # keep the 5 most relevant docs
|
|
469
|
+
)
|
|
470
|
+
# Reranked chunks' score is the cross-encoder relevance_score.
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`Reranker::Fixture` is a deterministic, zero-network reranker (lexical
|
|
474
|
+
token overlap) for tests. The `Reranker::Base` protocol validates inputs,
|
|
475
|
+
bounds `top_n`, rejects out-of-range indices, and sorts descending —
|
|
476
|
+
adapters implement only the network call (`#rerank_scores`).
|
|
477
|
+
|
|
478
|
+
> **Spend cap.** The `semantic_search` agent tool charges the estimated
|
|
479
|
+
> query-embedding tokens against the caller's tenant budget via
|
|
480
|
+
> `Parse::Embeddings::SpendCap` (opt-in; `configure(limit_tokens:,
|
|
481
|
+
> window:)`). A breach hard-refuses (surfaced to the agent as a
|
|
482
|
+
> rate-limited tool error). Admin agents are exempt; direct
|
|
483
|
+
> `find_similar` / `retrieve` callers are not metered.
|
|
400
484
|
|
|
401
485
|
### Chunkers
|
|
402
486
|
|
data/docs/client_sdk_guide.md
CHANGED
|
@@ -11,6 +11,11 @@ go over REST, and authorization is carried by the user's `sessionToken`.
|
|
|
11
11
|
Every claim below is locked in by the integration tests under
|
|
12
12
|
`test/lib/parse/client_*_integration_test.rb`.
|
|
13
13
|
|
|
14
|
+
For a runnable starting point, see
|
|
15
|
+
[`examples/basic_client.rb`](../examples/basic_client.rb) (a no-master client
|
|
16
|
+
with a row-level ACL-enforcement demo) and its master-key counterpart
|
|
17
|
+
[`examples/basic_server.rb`](../examples/basic_server.rb).
|
|
18
|
+
|
|
14
19
|
---
|
|
15
20
|
|
|
16
21
|
## Why a separate guide?
|
|
@@ -180,6 +185,39 @@ This duality is intentional. The high-level convenience method matches
|
|
|
180
185
|
what mobile SDKs do; the raw client preserves the response so you can
|
|
181
186
|
log or reroute.
|
|
182
187
|
|
|
188
|
+
#### A user-scoped client straight from login
|
|
189
|
+
|
|
190
|
+
`Parse::User#session_client` turns a logged-in user into a **non-master client
|
|
191
|
+
with that user's token bound**, so you don't thread `session_token:` through
|
|
192
|
+
every call. (`Parse.client.become(token)` builds the same thing from any token.)
|
|
193
|
+
`Parse::User#with_session` runs a block as the user:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
client = Parse::User.login("ada", "p4ssw0rd!").session_client
|
|
197
|
+
Parse::Query.new("Post", client: client).results # runs as Ada
|
|
198
|
+
|
|
199
|
+
# Or a block — every REST-routed op inside is authorized as Ada:
|
|
200
|
+
Parse::User.login("ada", "p4ssw0rd!").with_session do
|
|
201
|
+
Post.query.count
|
|
202
|
+
Post.create(title: "Hello")
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`session_client` returns `nil` if the user has no session token (e.g. it was
|
|
207
|
+
fetched/saved under the master key rather than logged in). The bound token is
|
|
208
|
+
applied as the lowest-priority auth fallback, so an explicit per-call
|
|
209
|
+
`session_token:`, a `Parse.with_session` block, or `use_master_key: true` all
|
|
210
|
+
still take precedence.
|
|
211
|
+
|
|
212
|
+
> **Scope boundary.** `with_session` (and `Parse.with_session`) authorize
|
|
213
|
+
> **REST-routed** operations (`find` / `get` / `count` / `save`) as the user.
|
|
214
|
+
> Mongo-direct queries (`results_direct`, `aggregate`, Atlas search) do **not**
|
|
215
|
+
> pick up the ambient session — scope them explicitly with a per-query
|
|
216
|
+
> `session_token:` or a scoped `Parse::Agent`. A no-master client like this one
|
|
217
|
+
> has no mongo-direct path anyway. To run a query as a user *without* a token —
|
|
218
|
+
> via the master key and SDK-side ACL simulation — use
|
|
219
|
+
> `Parse::Query#scope_to_user(user)`.
|
|
220
|
+
|
|
183
221
|
### 2.3 Validate / refresh a session
|
|
184
222
|
|
|
185
223
|
```ruby
|
data/docs/mcp_guide.md
CHANGED
|
@@ -7,7 +7,7 @@ The Model Context Protocol (MCP) is a standardized JSON-RPC 2.0-based interface
|
|
|
7
7
|
Three deployment modes are available:
|
|
8
8
|
|
|
9
9
|
- **Standalone HTTP server (`MCPServer`)** — a WEBrick process for dedicated MCP deployments.
|
|
10
|
-
- **Rack-mountable adapter (`MCPRackApp`)** — embeds inside an existing Sinatra or Rails application.
|
|
10
|
+
- **Rack-mountable adapter (`MCPRackApp`)** — embeds inside an existing Sinatra or Rails application. This is the primary deployment for the MCP 2025-06-18 Streamable HTTP transport; enable it with `transport: :streamable_http` (see [Streamable HTTP transport](#streamable-http-transport-primary)).
|
|
11
11
|
- **Direct in-process dispatcher (`MCPDispatcher`)** — a pure function for in-process usage, custom transports, and testing.
|
|
12
12
|
|
|
13
13
|
---
|
|
@@ -191,6 +191,42 @@ map("/mcp") { run mcp_app }
|
|
|
191
191
|
map("/") { run ->(env) { [200, {"Content-Type" => "text/plain"}, ["ok"]] } }
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
#### Streamable HTTP transport (primary)
|
|
195
|
+
|
|
196
|
+
The MCP 2025-06-18 **Streamable HTTP** transport is the recommended transport for `MCPRackApp`. It is a single connection model in which the client `POST`s JSON-RPC requests (receiving either a buffered JSON reply or, with `Accept: text/event-stream`, a streamed SSE reply) and holds open a long-lived `GET` request to receive server-initiated notifications. Session termination is signalled with `DELETE` carrying the `Mcp-Session-Id`.
|
|
197
|
+
|
|
198
|
+
Enable the whole transport with one switch:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
mcp_app = Parse::Agent.rack_app(transport: :streamable_http) do |env|
|
|
202
|
+
# ... auth factory ...
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`transport: :streamable_http` is exactly equivalent to `streaming: true, notifications: true` — it turns on POST→SSE streaming and the server→client `GET /` notification stream together. Add `resource_subscriptions: true` alongside it to upgrade the server→client bus from the plain notification posture to the LiveQuery-backed resource-subscription posture:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
mcp_app = Parse::Agent.rack_app(
|
|
210
|
+
transport: :streamable_http,
|
|
211
|
+
resource_subscriptions: true, # optional: bridge LiveQuery resource updates
|
|
212
|
+
) do |env|
|
|
213
|
+
# ...
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`transport:` is a closed enum:
|
|
218
|
+
|
|
219
|
+
| Value | Effect |
|
|
220
|
+
|-------|--------|
|
|
221
|
+
| `:streamable_http` | Full Streamable HTTP transport (`streaming: true` + `notifications: true`). |
|
|
222
|
+
| `:legacy` / `nil` (default) | Historical behavior: buffered JSON responses, no server→client stream. The standalone SSE/JSON path below remains a supported fallback. |
|
|
223
|
+
|
|
224
|
+
Passing `transport: :streamable_http` together with an explicit `streaming:` or `notifications:` raises `ArgumentError` (the switch already owns those toggles); any value other than the two above also raises. The default is unchanged, so an existing `Parse::Agent.rack_app { ... }` keeps its non-streaming JSON behavior until you opt in.
|
|
225
|
+
|
|
226
|
+
**WEBrick cannot deliver Streamable HTTP.** The switch — like `streaming:` — has no effect under the WEBrick-backed standalone `MCPServer`, which buffers responses and cannot hold the `GET` stream open. Use Puma, Falcon, or Unicorn for a real Streamable HTTP deployment.
|
|
227
|
+
|
|
228
|
+
The remaining subsections document the individual toggles `transport: :streamable_http` consolidates, for operators who need finer control or are reading older configurations.
|
|
229
|
+
|
|
194
230
|
#### MCP progress notifications via SSE (opt-in)
|
|
195
231
|
|
|
196
232
|
**WEBrick cannot stream.** The standalone `MCPServer` is WEBrick-based and buffers the full response before sending. Setting `streaming: true` on an `MCPRackApp` mounted under WEBrick silently degrades to a single buffered response with concatenated SSE events. SSE streaming requires a Rack server that supports streaming response bodies — **Puma, Falcon, or Unicorn**. Verify your deployment uses one of these before relying on `streaming: true`.
|
|
@@ -537,10 +573,29 @@ Parse Server version and its `masterKeyIps` configuration.)
|
|
|
537
573
|
soft cap *equal to* `max_concurrent_dispatchers`. So the effective steady-state
|
|
538
574
|
ceiling across both surfaces is up to **2× `max_concurrent_dispatchers`** (up
|
|
539
575
|
to N request-scoped SSE dispatchers plus N listening streams). Size the value
|
|
540
|
-
with that 2× factor in mind (e.g. relative to your Puma `max_threads`).
|
|
541
|
-
|
|
576
|
+
with that 2× factor in mind (e.g. relative to your Puma `max_threads`).
|
|
577
|
+
`max_concurrent_dispatchers:` defaults to a finite **100**
|
|
578
|
+
(`Parse::Agent::MCPRackApp::DEFAULT_MAX_CONCURRENT_DISPATCHERS`), so a
|
|
579
|
+
streaming surface is bounded out of the box — once the cap is reached a new
|
|
580
|
+
SSE request or listening stream is refused with a `503` JSON-RPC `-32000`
|
|
581
|
+
("server busy"). Pass an explicit positive integer to resize it, or
|
|
582
|
+
`max_concurrent_dispatchers: nil` to knowingly run uncapped (the app logs a
|
|
542
583
|
one-time warning at construction when a streaming or subscription/notification
|
|
543
|
-
surface is enabled
|
|
584
|
+
surface is enabled with `nil`). A non-positive or non-integer value raises
|
|
585
|
+
`ArgumentError`.
|
|
586
|
+
- **Client disconnect mid-tool-call.** When a client drops the connection while
|
|
587
|
+
a tool is still running, the SSE worker is torn down and the dispatcher's
|
|
588
|
+
cancellation token is tripped, so a cooperative tool (one that checks
|
|
589
|
+
`agent.cancelled?` at a checkpoint) exits promptly. A tool blocked inside a
|
|
590
|
+
Mongo/REST roundtrip cannot observe the token, but its slot is reclaimed when
|
|
591
|
+
the per-tool `Timeout` or the clean MongoDB `socket_timeout` (10s) / REST
|
|
592
|
+
`timeout` (30s) deadline fires — through the driver's clean error path. The
|
|
593
|
+
orphaned dispatcher is **intentionally not force-killed**: a `Thread#kill`
|
|
594
|
+
would bypass the driver's connection-invalidation and could return a half-used
|
|
595
|
+
pooled connection to a later request. To observe how often disconnects abandon
|
|
596
|
+
in-flight work, watch the cumulative
|
|
597
|
+
`Parse::Agent::MCPRackApp.abandoned_dispatcher_count` or subscribe to the
|
|
598
|
+
`parse.agent.mcp_dispatcher_abandoned` `ActiveSupport::Notifications` event.
|
|
544
599
|
|
|
545
600
|
### Listening-stream ownership
|
|
546
601
|
|
|
@@ -3106,6 +3161,66 @@ Four different refusal reasons each produce a distinct `:error_code` and message
|
|
|
3106
3161
|
|
|
3107
3162
|
**Resolution order at dispatch:** operator filter ▷ mutation gate ▷ mode ceiling ▷ in-tool class gate. Operator-filter precedence is deliberate — when a tool is excluded by both the operator's `tools: { except: [...] }` AND the mutation gate (or the mode ceiling), the operator-filter message wins so the operator looks at the right knob first. The mode-ceiling message names the tool, not the class — even when the request would have hit an `agent_hidden` class, the ceiling fires first for a refused tool, so the LLM does not learn anything about the class. For tools that pass the ceiling (e.g. `query_class`) the in-tool `assert_class_accessible!` runs next and the `agent_hidden` message echoes the class name supplied by the caller.
|
|
3108
3163
|
|
|
3164
|
+
### Client mode from a webhook — run a handler as the calling user (v5.3.0)
|
|
3165
|
+
|
|
3166
|
+
Parse Server includes the caller's live session token (`user.sessionToken`) in
|
|
3167
|
+
every trigger webhook fired by a logged-in user (it is absent for a master-key
|
|
3168
|
+
request). `Parse::Webhooks::Payload` captures that token before scrubbing it out
|
|
3169
|
+
of `payload.user` / `payload.object` (so it never lands in `payload.as_json` or
|
|
3170
|
+
the request log) and exposes two opt-in, user-scoped handles — the webhook
|
|
3171
|
+
counterpart of constructing a client-mode agent by hand:
|
|
3172
|
+
|
|
3173
|
+
```ruby
|
|
3174
|
+
Parse::Webhooks.route(:after_save, "Post") do
|
|
3175
|
+
# self is the Parse::Webhooks::Payload.
|
|
3176
|
+
next true unless session_token? # master-key save → no caller token
|
|
3177
|
+
|
|
3178
|
+
# A client-mode Parse::Agent bound to the caller — ACL/CLP enforced, no
|
|
3179
|
+
# master-key fallback. Same posture as Parse::Agent.new(session_token:, client: <no master_key>).
|
|
3180
|
+
visible = user_agent.execute(:query_class, class_name: "Post", limit: 20)
|
|
3181
|
+
|
|
3182
|
+
# …or a raw user-scoped Parse::Client (token is BOUND, so plain REST calls
|
|
3183
|
+
# are authorized as the user with no per-call session_token: needed):
|
|
3184
|
+
mine = user_client.request(:get, "classes/Post").result
|
|
3185
|
+
true
|
|
3186
|
+
end
|
|
3187
|
+
```
|
|
3188
|
+
|
|
3189
|
+
| Payload handle | Returns | `nil` when |
|
|
3190
|
+
|----------------|---------|-----------|
|
|
3191
|
+
| `payload.session_token` | the caller's raw token (`String`) | master-key request (no user) |
|
|
3192
|
+
| `payload.user_agent(**opts)` | non-master `Parse::Agent` in **client mode**, token bound | no token |
|
|
3193
|
+
| `payload.user_client` | non-master `Parse::Client` with the token **bound** | no token |
|
|
3194
|
+
|
|
3195
|
+
`user_client` binds the token via the new `Parse::Client.new(session_token:)`
|
|
3196
|
+
option, applied as the lowest-priority auth fallback on every request — an
|
|
3197
|
+
explicit per-call `session_token:`, a `Parse.with_session` block, or an explicit
|
|
3198
|
+
`use_master_key: true` all still take precedence. Everything the Client Mode
|
|
3199
|
+
ceiling above says about a hand-built client-mode agent applies verbatim to
|
|
3200
|
+
`payload.user_agent`: read tools only unless `allow_mutations: true`, and
|
|
3201
|
+
`acl_user:` / `acl_role:` are not available on a no-master client.
|
|
3202
|
+
|
|
3203
|
+
The same user-scoped client is available client-side from a login
|
|
3204
|
+
(`Parse::User#session_client`, or `Parse.client.become(token)` from any token),
|
|
3205
|
+
and `Parse::User#with_session` / `Parse::Client#with_session` run a block as the
|
|
3206
|
+
user so ordinary model operations are implicitly scoped:
|
|
3207
|
+
|
|
3208
|
+
```ruby
|
|
3209
|
+
client = Parse::User.login(username, password).session_client # non-master, token bound
|
|
3210
|
+
Parse::Query.new("Post", client: client).results # query as the user
|
|
3211
|
+
Parse::User.login(username, password).with_session { Post.query.count }
|
|
3212
|
+
```
|
|
3213
|
+
|
|
3214
|
+
`with_session` (and `Parse.with_session`) authorize **REST-routed** operations
|
|
3215
|
+
(`find` / `get` / `count` / `save`) as the user. Mongo-direct queries
|
|
3216
|
+
(`results_direct`, `aggregate`, Atlas search) do NOT pick up the ambient
|
|
3217
|
+
session — they resolve auth from the query's own `session_token:` / `acl_user:`
|
|
3218
|
+
and otherwise run in **master** mode (a full master read, not anonymous), so
|
|
3219
|
+
scope them explicitly with a per-query `session_token:` or a scoped
|
|
3220
|
+
`Parse::Agent`. This is deliberate: mongo-direct scoping is always explicit in
|
|
3221
|
+
this SDK, so the ambient fiber state can never silently flip a mongo-direct
|
|
3222
|
+
query into user scope (or be mistaken for it).
|
|
3223
|
+
|
|
3109
3224
|
---
|
|
3110
3225
|
|
|
3111
3226
|
## `agent_hidden` — Per-Class Agent-Surface Denial
|