parse-stack-next 5.5.4 → 5.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -6
- data/README.md +26 -13
- data/bin/parse-console +9 -1
- data/docs/TEST_SERVER.md +115 -238
- data/docs/mcp_guide.md +1 -1
- data/docs/mongodb_index_optimization_guide.md +3 -2
- data/docs/usage_guide.md +1 -1
- data/docs/yard-template/default/fulldoc/html/css/common.css +52 -9
- data/docs/yard-template/default/fulldoc/html/css/full_list.css +40 -13
- data/lib/parse/agent/constraint_translator.rb +18 -18
- data/lib/parse/agent/errors.rb +29 -7
- data/lib/parse/agent/metadata_dsl.rb +6 -6
- data/lib/parse/agent/tools.rb +250 -59
- data/lib/parse/agent.rb +42 -30
- data/lib/parse/api/aggregate.rb +3 -3
- data/lib/parse/api/cloud_functions.rb +19 -10
- data/lib/parse/api/objects.rb +8 -8
- data/lib/parse/api/users.rb +9 -9
- data/lib/parse/atlas_search/session.rb +34 -34
- data/lib/parse/atlas_search.rb +243 -110
- data/lib/parse/client/body_builder.rb +10 -10
- data/lib/parse/client/logging.rb +5 -2
- data/lib/parse/client/profiling.rb +5 -2
- data/lib/parse/client/protocol.rb +1 -1
- data/lib/parse/client/url_redaction.rb +94 -0
- data/lib/parse/client.rb +43 -28
- data/lib/parse/embeddings/image_fetch.rb +6 -1
- data/lib/parse/embeddings/voyage.rb +16 -17
- data/lib/parse/live_query/client.rb +7 -7
- data/lib/parse/live_query/subscription.rb +1 -1
- data/lib/parse/lock.rb +1 -1
- data/lib/parse/lock_backend.rb +118 -2
- data/lib/parse/model/acl.rb +24 -24
- data/lib/parse/model/classes/job_schedule.rb +8 -8
- data/lib/parse/model/classes/job_status.rb +9 -9
- data/lib/parse/model/classes/role.rb +49 -49
- data/lib/parse/model/classes/session.rb +2 -2
- data/lib/parse/model/classes/user.rb +66 -66
- data/lib/parse/model/core/builder.rb +7 -7
- data/lib/parse/model/core/create_lock.rb +1 -1
- data/lib/parse/model/core/properties.rb +4 -4
- data/lib/parse/model/file.rb +57 -16
- data/lib/parse/model/model.rb +19 -19
- data/lib/parse/model/object.rb +38 -38
- data/lib/parse/model/pointer.rb +4 -4
- data/lib/parse/model/push.rb +5 -5
- data/lib/parse/mongodb.rb +84 -26
- data/lib/parse/pipeline_security.rb +2 -2
- data/lib/parse/query/constraints.rb +38 -38
- data/lib/parse/query.rb +151 -75
- data/lib/parse/retrieval/reranker/cohere.rb +30 -0
- data/lib/parse/schema.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -10
- data/lib/parse/two_factor_auth/user_extension.rb +25 -25
- data/lib/parse/webhooks/payload.rb +35 -35
- data/lib/parse/webhooks/registration.rb +2 -2
- data/lib/parse/webhooks/replay_protection.rb +16 -16
- data/lib/parse/webhooks.rb +11 -11
- data/parse-stack-next.gemspec +19 -1
- metadata +2 -38
- data/.bundle/config +0 -5
- data/.env.sample +0 -138
- data/.env.test +0 -10
- data/.github/ISSUE_TEMPLATE/bug_report.yml +0 -105
- data/.github/ISSUE_TEMPLATE/feature_request.yml +0 -67
- data/.github/dependabot.yml +0 -13
- data/.github/workflows/codeql.yml +0 -44
- data/.github/workflows/docs.yml +0 -39
- data/.github/workflows/release.yml +0 -43
- data/.github/workflows/ruby.yml +0 -38
- data/.gitignore +0 -56
- data/.ruby-version +0 -1
- data/.solargraph.yml +0 -22
- data/.vscode/settings.json +0 -3
- data/.yardopts +0 -19
- data/Gemfile +0 -43
- data/Gemfile.lock +0 -198
- data/Makefile +0 -63
- data/Rakefile +0 -825
- data/config/parse-config.json +0 -12
- data/scripts/debug-ips.js +0 -35
- data/scripts/docker/Dockerfile.parse +0 -17
- data/scripts/docker/atlas-init.js +0 -284
- data/scripts/docker/docker-compose.atlas.yml +0 -80
- data/scripts/docker/docker-compose.test.yml +0 -159
- data/scripts/docker/docker-compose.verifyemail.yml +0 -4
- data/scripts/docker/mongo-init.js +0 -21
- data/scripts/docker/preflight.sh +0 -76
- data/scripts/eval_mcp_with_lm_studio.rb +0 -274
- data/scripts/start-parse.sh +0 -154
- data/scripts/start_mcp_server.rb +0 -78
- data/scripts/test_server_connection.rb +0 -82
- data/scripts/vector_prototype/create_vector_index.js +0 -105
- data/scripts/vector_prototype/fetch_embeddings.py +0 -241
- data/scripts/vector_prototype/fixture_manifest.json +0 -9
- data/scripts/vector_prototype/query_prototype.rb +0 -84
- data/scripts/vector_prototype/run.sh +0 -34
data/Rakefile
DELETED
|
@@ -1,825 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env rake
|
|
2
|
-
require "bundler/gem_tasks"
|
|
3
|
-
require "yard"
|
|
4
|
-
require "rake/testtask"
|
|
5
|
-
|
|
6
|
-
# Several MCP/debug tasks need to run `Parse.setup(...)` against a
|
|
7
|
-
# local Parse instance. This helper preserves the local-stack
|
|
8
|
-
# convenience defaults while refusing to apply those defaults against
|
|
9
|
-
# anything that isn't a loopback URL — so a developer who pointed
|
|
10
|
-
# `PARSE_SERVER_URL` at a real Parse Server but forgot to set the
|
|
11
|
-
# secret env vars gets a loud abort instead of a silent boot with
|
|
12
|
-
# placeholder credentials.
|
|
13
|
-
#
|
|
14
|
-
# @return [Array(String, String, String, String)]
|
|
15
|
-
# server_url, application_id, api_key, master_key
|
|
16
|
-
def mcp_credentials_or_abort!
|
|
17
|
-
server_url = ENV["PARSE_SERVER_URL"] || "http://localhost:29337/parse"
|
|
18
|
-
app_id = ENV["PARSE_APP_ID"]
|
|
19
|
-
rest_api_key = ENV["PARSE_API_KEY"]
|
|
20
|
-
master_key = ENV["PARSE_MASTER_KEY"]
|
|
21
|
-
|
|
22
|
-
is_local = server_url =~ %r{\Ahttps?://(?:localhost|127\.0\.0\.1|::1|\[::1\])(?::|/|\z)}
|
|
23
|
-
|
|
24
|
-
if app_id.to_s.empty? || master_key.to_s.empty?
|
|
25
|
-
if is_local
|
|
26
|
-
app_id = (app_id.to_s.empty? ? "psnextItAppId" : app_id)
|
|
27
|
-
rest_api_key = (rest_api_key.to_s.empty? ? "myApiKey" : rest_api_key)
|
|
28
|
-
master_key = (master_key.to_s.empty? ? "psnextItMasterKey" : master_key)
|
|
29
|
-
else
|
|
30
|
-
abort "[Rakefile] PARSE_SERVER_URL=#{server_url} is not local; refusing to fall back to " \
|
|
31
|
-
"placeholder credentials. Set PARSE_APP_ID and PARSE_MASTER_KEY explicitly."
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
[server_url, app_id, rest_api_key, master_key]
|
|
36
|
-
end
|
|
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
|
-
|
|
131
|
-
# Default test task runs all tests with Docker enabled.
|
|
132
|
-
#
|
|
133
|
-
# `*disruptive*` tests are EXCLUDED here: they stop/restart the shared
|
|
134
|
-
# Parse Server container, which would flake any other test loaded into the
|
|
135
|
-
# same process. Run them on their own via `rake test:integration:disruptive`.
|
|
136
|
-
Rake::TestTask.new do |t|
|
|
137
|
-
ENV['PARSE_TEST_USE_DOCKER'] = 'true'
|
|
138
|
-
t.libs << "lib/parse/stack"
|
|
139
|
-
t.test_files = FileList["test/lib/**/*_test.rb"].exclude("test/lib/**/*disruptive*")
|
|
140
|
-
t.warning = false
|
|
141
|
-
t.verbose = true
|
|
142
|
-
end
|
|
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
|
-
|
|
214
|
-
# Integration tests require Docker
|
|
215
|
-
namespace :test do
|
|
216
|
-
desc "Run all integration tests (requires Docker). " \
|
|
217
|
-
"Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
|
|
218
|
-
task :integration do
|
|
219
|
-
# Disruptive tests (server stop/restart) are run separately via
|
|
220
|
-
# `test:integration:disruptive` so they never interleave with — and
|
|
221
|
-
# flake — the rest of the integration suite against the shared server.
|
|
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")
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
desc "Run unit tests only (no Docker required). " \
|
|
228
|
-
"Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
|
|
229
|
-
task :unit do
|
|
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")
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
namespace :integration do
|
|
237
|
-
desc "Run DISRUPTIVE integration tests (stop/restart the Parse Server " \
|
|
238
|
-
"container). Run in isolation — these are excluded from the normal " \
|
|
239
|
-
"test / test:integration / test:unit runs."
|
|
240
|
-
task :disruptive do
|
|
241
|
-
disruptive_files = FileList["test/lib/**/*disruptive*_test.rb"]
|
|
242
|
-
|
|
243
|
-
if disruptive_files.empty?
|
|
244
|
-
puts "No disruptive test files found."
|
|
245
|
-
next
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
puts "Running #{disruptive_files.length} disruptive test file(s)..."
|
|
249
|
-
disruptive_files.each_with_index do |file, index|
|
|
250
|
-
puts "\n" + "=" * 80
|
|
251
|
-
puts "Running disruptive test #{index + 1}/#{disruptive_files.length}: #{file}"
|
|
252
|
-
puts "=" * 80
|
|
253
|
-
# Each file runs in its own process so a server outage in one cannot
|
|
254
|
-
# bleed into the next.
|
|
255
|
-
system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}") || begin
|
|
256
|
-
# A disruptive test may have left the server down on failure; bring
|
|
257
|
-
# it back so a follow-up run / other tasks start from a clean state.
|
|
258
|
-
system("docker start #{ENV["PSNEXT_PREFIX"] || "psnext-it"}-server", out: IO::NULL, err: IO::NULL)
|
|
259
|
-
exit(1)
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
puts "\n✅ All disruptive tests completed successfully!"
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
desc "List all available test files"
|
|
267
|
-
task :list do
|
|
268
|
-
puts "\nIntegration Tests:"
|
|
269
|
-
FileList["test/lib/**/*integration_test.rb"].exclude("test/lib/**/*disruptive*").each { |f| puts " #{f}" }
|
|
270
|
-
|
|
271
|
-
puts "\nDisruptive Integration Tests (run via test:integration:disruptive):"
|
|
272
|
-
FileList["test/lib/**/*disruptive*_test.rb"].each { |f| puts " #{f}" }
|
|
273
|
-
|
|
274
|
-
puts "\nUnit Tests:"
|
|
275
|
-
FileList["test/lib/**/*_test.rb"]
|
|
276
|
-
.exclude("test/lib/**/*integration_test.rb")
|
|
277
|
-
.exclude("test/lib/**/*disruptive*")
|
|
278
|
-
.each { |f| puts " #{f}" }
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
# ---------------------------------------------------------------------------
|
|
282
|
-
# MCP protocol conformance via Anthropic's official mcp-inspector tool.
|
|
283
|
-
#
|
|
284
|
-
# Boots a local MCPServer against a configured Parse Server, then runs
|
|
285
|
-
# @modelcontextprotocol/inspector in CLI mode to validate the MCP wire
|
|
286
|
-
# protocol (initialize handshake, tools/list, tools/call, prompts/list,
|
|
287
|
-
# resources/list, error envelopes). Catches protocol regressions that
|
|
288
|
-
# in-process integration tests can miss because they exercise the Ruby
|
|
289
|
-
# call surface, not the JSON wire format an external MCP client sees.
|
|
290
|
-
#
|
|
291
|
-
# Requirements:
|
|
292
|
-
# - npx on PATH (Node.js 18+)
|
|
293
|
-
# - A running Parse Server (e.g., `docker-compose -f scripts/docker/
|
|
294
|
-
# docker-compose.test.yml up -d`)
|
|
295
|
-
# - Env: PARSE_SERVER_URL, PARSE_APP_ID, PARSE_API_KEY (defaults match
|
|
296
|
-
# the Docker compose setup in scripts/docker/docker-compose.test.yml)
|
|
297
|
-
#
|
|
298
|
-
# Usage:
|
|
299
|
-
# rake test:mcp_inspector
|
|
300
|
-
# rake test:mcp_inspector METHOD=tools/list # override target method
|
|
301
|
-
# ---------------------------------------------------------------------------
|
|
302
|
-
desc "Validate MCP protocol with Anthropic's mcp-inspector (requires npx)"
|
|
303
|
-
task :mcp_inspector do
|
|
304
|
-
require "net/http"
|
|
305
|
-
require "uri"
|
|
306
|
-
require "fileutils"
|
|
307
|
-
|
|
308
|
-
unless system("which npx > /dev/null 2>&1")
|
|
309
|
-
abort "[mcp_inspector] npx not found on PATH. Install Node.js 18+ or use `nvm use 18`."
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
port = ENV["MCP_INSPECTOR_PORT"] || "3099"
|
|
313
|
-
api_key = ENV["MCP_INSPECTOR_KEY"] || "rake-inspector-key"
|
|
314
|
-
method = ENV["METHOD"] || "tools/list"
|
|
315
|
-
|
|
316
|
-
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
|
|
317
|
-
|
|
318
|
-
boot = <<~RUBY
|
|
319
|
-
$LOAD_PATH.unshift(File.expand_path('lib'))
|
|
320
|
-
require 'parse-stack'
|
|
321
|
-
Parse.setup(
|
|
322
|
-
server_url: #{server_url.inspect},
|
|
323
|
-
application_id: #{app_id.inspect},
|
|
324
|
-
api_key: #{rest_api_key.inspect},
|
|
325
|
-
master_key: #{master_key.inspect},
|
|
326
|
-
)
|
|
327
|
-
ENV['PARSE_MCP_ENABLED'] = 'true'
|
|
328
|
-
Parse.mcp_server_enabled = true
|
|
329
|
-
Parse::Agent.mcp_enabled = true
|
|
330
|
-
require 'parse/agent/mcp_server'
|
|
331
|
-
Parse::Agent::MCPServer.run(
|
|
332
|
-
port: #{port.to_i},
|
|
333
|
-
host: '127.0.0.1',
|
|
334
|
-
permissions: :readonly,
|
|
335
|
-
api_key: #{api_key.inspect},
|
|
336
|
-
)
|
|
337
|
-
RUBY
|
|
338
|
-
|
|
339
|
-
log_path = "tmp/mcp-inspector-server.log"
|
|
340
|
-
FileUtils.mkdir_p("tmp")
|
|
341
|
-
pid = Process.spawn("ruby", "-e", boot, out: log_path, err: log_path)
|
|
342
|
-
|
|
343
|
-
begin
|
|
344
|
-
ready = false
|
|
345
|
-
40.times do
|
|
346
|
-
sleep 0.25
|
|
347
|
-
begin
|
|
348
|
-
uri = URI("http://127.0.0.1:#{port}/health")
|
|
349
|
-
ready = (Net::HTTP.get_response(uri).code == "200")
|
|
350
|
-
break if ready
|
|
351
|
-
rescue Errno::ECONNREFUSED, Errno::EADDRINUSE
|
|
352
|
-
# retry
|
|
353
|
-
end
|
|
354
|
-
end
|
|
355
|
-
unless ready
|
|
356
|
-
warn "[mcp_inspector] MCPServer failed to become healthy on port #{port}. Server log:"
|
|
357
|
-
warn(File.read(log_path)) rescue nil
|
|
358
|
-
abort "[mcp_inspector] aborting"
|
|
359
|
-
end
|
|
360
|
-
puts "[mcp_inspector] MCPServer healthy on http://127.0.0.1:#{port}"
|
|
361
|
-
|
|
362
|
-
cmd = [
|
|
363
|
-
"npx", "--yes", "@modelcontextprotocol/inspector",
|
|
364
|
-
"--cli", "http://127.0.0.1:#{port}/mcp",
|
|
365
|
-
"--method", method,
|
|
366
|
-
"--header", "X-MCP-API-Key:#{api_key}",
|
|
367
|
-
]
|
|
368
|
-
puts "[mcp_inspector] $ #{cmd.join(" ")}"
|
|
369
|
-
ok = system(*cmd)
|
|
370
|
-
abort "[mcp_inspector] inspector exited non-zero" unless ok
|
|
371
|
-
puts "[mcp_inspector] protocol check passed"
|
|
372
|
-
ensure
|
|
373
|
-
if pid
|
|
374
|
-
Process.kill("TERM", pid) rescue nil
|
|
375
|
-
Process.wait(pid) rescue nil
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
task :default => :test
|
|
382
|
-
|
|
383
|
-
task :console do
|
|
384
|
-
exec "./bin/console"
|
|
385
|
-
end
|
|
386
|
-
task :c => :console
|
|
387
|
-
|
|
388
|
-
# ===========================================================================
|
|
389
|
-
# MCP namespace: interactive REPL and one-shot tool dispatch.
|
|
390
|
-
# ===========================================================================
|
|
391
|
-
namespace :mcp do
|
|
392
|
-
# -------------------------------------------------------------------------
|
|
393
|
-
# rake mcp:console
|
|
394
|
-
#
|
|
395
|
-
# Drops you into an IRB session with a pre-configured Parse::Agent and
|
|
396
|
-
# MCP helpers bound at the top level. Talk to the agent the same way an
|
|
397
|
-
# LLM would, but interactively from your terminal.
|
|
398
|
-
#
|
|
399
|
-
# Setup:
|
|
400
|
-
# - .env (or shell env) provides PARSE_SERVER_URL / PARSE_APP_ID /
|
|
401
|
-
# PARSE_API_KEY / PARSE_MASTER_KEY. Defaults match the Docker
|
|
402
|
-
# compose harness in scripts/docker/docker-compose.test.yml.
|
|
403
|
-
# - Optionally MCP_AGENT_PERMISSIONS=readonly|write|admin
|
|
404
|
-
# (default :readonly).
|
|
405
|
-
#
|
|
406
|
-
# Bindings available in the REPL:
|
|
407
|
-
# agent — the Parse::Agent instance.
|
|
408
|
-
# tools — print every tool the agent has access to.
|
|
409
|
-
# schemas — print every visible class name.
|
|
410
|
-
# t(name, **kwargs) — invoke a tool, return its result hash.
|
|
411
|
-
# q(class_name, ...) — shortcut for t(:query_class, class_name:, **opts).
|
|
412
|
-
# count(class_name) — shortcut for t(:count_objects, class_name:, ...).
|
|
413
|
-
# schema(class_name) — shortcut for t(:get_schema, class_name:).
|
|
414
|
-
# dispatch(method, params={}) — call MCPDispatcher.call(body:, agent:).
|
|
415
|
-
# prompts — print every registered + builtin prompt.
|
|
416
|
-
# render_prompt(name, args={}) — render a prompt to its message envelope.
|
|
417
|
-
#
|
|
418
|
-
# Example session:
|
|
419
|
-
# $ bundle exec rake mcp:console
|
|
420
|
-
# irb> tools
|
|
421
|
-
# irb> q("MCPSchoolTeacher", limit: 3)
|
|
422
|
-
# irb> count("MCPSchoolStudent")
|
|
423
|
-
# irb> dispatch("initialize")
|
|
424
|
-
# -------------------------------------------------------------------------
|
|
425
|
-
desc "Interactive MCP REPL: query a Parse::Agent like an LLM would, but with Ruby"
|
|
426
|
-
task :console do
|
|
427
|
-
require "irb"
|
|
428
|
-
require "json"
|
|
429
|
-
# dotenv is in the Gemfile :test, :development group; load .env if present.
|
|
430
|
-
begin
|
|
431
|
-
require "dotenv/load"
|
|
432
|
-
rescue LoadError
|
|
433
|
-
# dotenv not installed; rely on shell env vars
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
|
|
437
|
-
require "parse-stack"
|
|
438
|
-
require "parse/agent"
|
|
439
|
-
require "parse/agent/mcp_dispatcher"
|
|
440
|
-
require "parse/agent/prompts"
|
|
441
|
-
require "parse/agent/mcp_client"
|
|
442
|
-
|
|
443
|
-
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
|
|
444
|
-
permissions = (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym
|
|
445
|
-
|
|
446
|
-
Parse.setup(
|
|
447
|
-
server_url: server_url,
|
|
448
|
-
application_id: app_id,
|
|
449
|
-
api_key: rest_api_key,
|
|
450
|
-
master_key: master_key,
|
|
451
|
-
)
|
|
452
|
-
|
|
453
|
-
agent = Parse::Agent.new(permissions: permissions)
|
|
454
|
-
|
|
455
|
-
# Bind helpers as singleton methods on TOPLEVEL_BINDING so they're
|
|
456
|
-
# callable bare in the IRB session without a receiver.
|
|
457
|
-
Object.send(:define_method, :agent) { agent }
|
|
458
|
-
Object.send(:define_method, :_mcp_agent_const) { agent }
|
|
459
|
-
|
|
460
|
-
Object.send(:define_method, :tools) do
|
|
461
|
-
list = agent.tool_definitions(format: :mcp).map { |t| t[:name] || t["name"] }
|
|
462
|
-
puts list.sort.join("\n")
|
|
463
|
-
list.size
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
Object.send(:define_method, :schemas) do
|
|
467
|
-
result = agent.execute(:get_all_schemas)
|
|
468
|
-
unless result[:success]
|
|
469
|
-
puts "get_all_schemas failed: #{result[:error]}"
|
|
470
|
-
next nil
|
|
471
|
-
end
|
|
472
|
-
custom = (result[:data][:custom] || []).map { |c| c[:name] }
|
|
473
|
-
built_in = (result[:data][:built_in] || []).map { |c| c[:name] }
|
|
474
|
-
puts "Custom: #{custom.sort.join(", ")}"
|
|
475
|
-
puts "Built-in: #{built_in.sort.join(", ")}"
|
|
476
|
-
custom + built_in
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
Object.send(:define_method, :t) do |name, **kwargs|
|
|
480
|
-
agent.execute(name.to_sym, **kwargs)
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
Object.send(:define_method, :q) do |class_name, **opts|
|
|
484
|
-
t(:query_class, class_name: class_name, **opts)
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
Object.send(:define_method, :count) do |class_name, **opts|
|
|
488
|
-
t(:count_objects, class_name: class_name, **opts)
|
|
489
|
-
end
|
|
490
|
-
|
|
491
|
-
Object.send(:define_method, :schema) do |class_name|
|
|
492
|
-
t(:get_schema, class_name: class_name)
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
Object.send(:define_method, :dispatch) do |method, params = {}|
|
|
496
|
-
body = { "jsonrpc" => "2.0", "id" => SecureRandom.hex(4), "method" => method.to_s, "params" => params }
|
|
497
|
-
Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
|
|
498
|
-
end
|
|
499
|
-
|
|
500
|
-
Object.send(:define_method, :prompts) do
|
|
501
|
-
list = Parse::Agent::Prompts.list.map { |p| p["name"] }
|
|
502
|
-
puts list.sort.join("\n")
|
|
503
|
-
list.size
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
Object.send(:define_method, :render_prompt) do |name, args = {}|
|
|
507
|
-
Parse::Agent::Prompts.render(name.to_s, args.transform_keys(&:to_s))
|
|
508
|
-
end
|
|
509
|
-
|
|
510
|
-
# When LLM_PROVIDER + LLM_API_KEY are in env (e.g. via .env), bind
|
|
511
|
-
# `mcp` as a conversational client. Lets you do:
|
|
512
|
-
# mcp.ask("how many students?")
|
|
513
|
-
# _.reply("just for Ms. Vasquez")
|
|
514
|
-
mcp = nil
|
|
515
|
-
if ENV["LLM_PROVIDER"]
|
|
516
|
-
begin
|
|
517
|
-
mcp = Parse::Agent::MCPClient.new(agent: agent)
|
|
518
|
-
Object.send(:define_method, :mcp) { mcp }
|
|
519
|
-
rescue ArgumentError => e
|
|
520
|
-
puts "[mcp:console] could not initialize MCPClient — #{e.message}"
|
|
521
|
-
puts "[mcp:console] set LLM_PROVIDER + LLM_API_KEY in your .env (see .env.sample)"
|
|
522
|
-
end
|
|
523
|
-
end
|
|
524
|
-
|
|
525
|
-
puts "=" * 70
|
|
526
|
-
puts "Parse::Agent MCP Console"
|
|
527
|
-
puts "=" * 70
|
|
528
|
-
puts "Server: #{server_url}"
|
|
529
|
-
puts "Permissions: #{permissions}"
|
|
530
|
-
puts "Agent: #{agent.class.name} (#{agent.allowed_tools.size} tools)"
|
|
531
|
-
puts "LLM client: " + (mcp ? "#{mcp.provider} / #{mcp.model}" : "DISABLED (set LLM_PROVIDER + LLM_API_KEY to enable mcp.ask)")
|
|
532
|
-
puts
|
|
533
|
-
puts "Try:"
|
|
534
|
-
if mcp
|
|
535
|
-
puts " mcp.ask('how many students do we have?')"
|
|
536
|
-
puts " _.reply('what about just for Ms. Vasquez?') # chain replies"
|
|
537
|
-
puts
|
|
538
|
-
end
|
|
539
|
-
puts " tools # list available tools"
|
|
540
|
-
puts " schemas # list visible Parse classes"
|
|
541
|
-
puts " q('User', limit: 3) # query_class shortcut"
|
|
542
|
-
puts " count('Song') # count_objects shortcut"
|
|
543
|
-
puts " schema('Song') # get_schema shortcut"
|
|
544
|
-
puts " t(:query_class, class_name: 'Song', where: { name: 'X' })"
|
|
545
|
-
puts " dispatch('tools/list') # MCPDispatcher round-trip"
|
|
546
|
-
puts " prompts # list registered prompts"
|
|
547
|
-
puts " render_prompt('parse_conventions')"
|
|
548
|
-
puts "=" * 70
|
|
549
|
-
|
|
550
|
-
IRB.start
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
# -------------------------------------------------------------------------
|
|
554
|
-
# rake mcp:chat
|
|
555
|
-
#
|
|
556
|
-
# Conversational CLI loop — talk to your Parse database via the MCP agent
|
|
557
|
-
# in plain English. Each turn drives the LLM through tool calls and prints
|
|
558
|
-
# the final answer; context persists across turns. Like a tiny REPL just
|
|
559
|
-
# for the MCP agent.
|
|
560
|
-
#
|
|
561
|
-
# Setup:
|
|
562
|
-
# - .env (or shell env) with LLM_PROVIDER + LLM_API_KEY (see .env.sample)
|
|
563
|
-
# - PARSE_SERVER_URL / PARSE_APP_ID / PARSE_API_KEY / PARSE_MASTER_KEY
|
|
564
|
-
# (defaults match the Docker compose harness)
|
|
565
|
-
#
|
|
566
|
-
# Slash commands inside the loop:
|
|
567
|
-
# /reset — start a fresh conversation (clear history)
|
|
568
|
-
# /compact — replace history with an LLM-generated summary (1 extra call)
|
|
569
|
-
# /tools — list available MCP tools
|
|
570
|
-
# /trace — toggle tool-call tracing on/off
|
|
571
|
-
# /cost — show running token + USD cost totals
|
|
572
|
-
# /history — print conversation history
|
|
573
|
-
# /exit — leave the chat (also: /quit, exit, quit, Ctrl-D, empty line)
|
|
574
|
-
# -------------------------------------------------------------------------
|
|
575
|
-
desc "Conversational CLI: talk to your Parse data via the MCP agent"
|
|
576
|
-
task :chat do
|
|
577
|
-
begin
|
|
578
|
-
require "dotenv/load"
|
|
579
|
-
rescue LoadError
|
|
580
|
-
end
|
|
581
|
-
|
|
582
|
-
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
|
|
583
|
-
require "parse-stack"
|
|
584
|
-
require "parse/agent"
|
|
585
|
-
require "parse/agent/mcp_client"
|
|
586
|
-
|
|
587
|
-
unless ENV["LLM_PROVIDER"]
|
|
588
|
-
abort "[mcp:chat] LLM_PROVIDER is not set. Add it to .env (see .env.sample). " \
|
|
589
|
-
"Supported providers: openai, anthropic, lmstudio."
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
|
|
593
|
-
Parse.setup(
|
|
594
|
-
server_url: server_url,
|
|
595
|
-
application_id: app_id,
|
|
596
|
-
api_key: rest_api_key,
|
|
597
|
-
master_key: master_key,
|
|
598
|
-
)
|
|
599
|
-
|
|
600
|
-
permissions = (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym
|
|
601
|
-
agent = Parse::Agent.new(permissions: permissions)
|
|
602
|
-
client = Parse::Agent::MCPClient.new(agent: agent)
|
|
603
|
-
trace = (ENV["MCP_CHAT_TRACE"] || "false") == "true"
|
|
604
|
-
|
|
605
|
-
slash_help = lambda do
|
|
606
|
-
puts "Slash commands:"
|
|
607
|
-
puts " /help — print this list"
|
|
608
|
-
puts " /reset — clear conversation history"
|
|
609
|
-
puts " /compact — replace history with an LLM-generated summary"
|
|
610
|
-
puts " /tools — list MCP tools the agent has access to"
|
|
611
|
-
puts " /trace — toggle per-turn tool-call tracing on/off"
|
|
612
|
-
puts " /cost — show running token + USD totals (and last turn)"
|
|
613
|
-
puts " /history — print the conversation log"
|
|
614
|
-
puts " /exit — leave (also /quit, exit, quit, Ctrl-D, empty line)"
|
|
615
|
-
end
|
|
616
|
-
|
|
617
|
-
puts "=" * 70
|
|
618
|
-
puts "Parse MCP Chat — #{client.provider} / #{client.model}"
|
|
619
|
-
puts "Permissions: #{permissions} | Trace: #{trace ? "on" : "off"}"
|
|
620
|
-
puts "Type your question. Type /help for slash commands."
|
|
621
|
-
puts "=" * 70
|
|
622
|
-
|
|
623
|
-
loop do
|
|
624
|
-
print "\n> "
|
|
625
|
-
line = $stdin.gets
|
|
626
|
-
break if line.nil? # Ctrl-D
|
|
627
|
-
line = line.strip
|
|
628
|
-
next if line.empty?
|
|
629
|
-
|
|
630
|
-
case line
|
|
631
|
-
when "/exit", "/quit", "exit", "quit"
|
|
632
|
-
break
|
|
633
|
-
when "/help"
|
|
634
|
-
slash_help.call
|
|
635
|
-
next
|
|
636
|
-
when "/reset"
|
|
637
|
-
client.reset!
|
|
638
|
-
puts "[conversation cleared]"
|
|
639
|
-
next
|
|
640
|
-
when "/compact"
|
|
641
|
-
before = client.usage.total_tokens
|
|
642
|
-
summary = client.compact!
|
|
643
|
-
if summary.empty?
|
|
644
|
-
puts "[nothing to compact]"
|
|
645
|
-
else
|
|
646
|
-
delta = client.usage.total_tokens - before
|
|
647
|
-
puts "[compacted; +#{delta} tokens spent on summary]"
|
|
648
|
-
puts " summary: #{summary[0, 200]}#{summary.length > 200 ? "…" : ""}"
|
|
649
|
-
end
|
|
650
|
-
next
|
|
651
|
-
when "/tools"
|
|
652
|
-
puts agent.tool_definitions(format: :mcp).map { |t| t[:name] || t["name"] }.sort.join("\n")
|
|
653
|
-
next
|
|
654
|
-
when "/trace"
|
|
655
|
-
trace = !trace
|
|
656
|
-
puts "[trace #{trace ? "on" : "off"}]"
|
|
657
|
-
next
|
|
658
|
-
when "/cost"
|
|
659
|
-
u = client.usage
|
|
660
|
-
last = client.last_call_usage
|
|
661
|
-
printf " session: %d in + %d out = %d tokens $%.4f\n",
|
|
662
|
-
u.prompt_tokens, u.completion_tokens, u.total_tokens, u.cost_usd
|
|
663
|
-
if last
|
|
664
|
-
printf " last: %d in + %d out = %d tokens $%.6f\n",
|
|
665
|
-
last.prompt_tokens, last.completion_tokens, last.total_tokens, last.cost_usd
|
|
666
|
-
end
|
|
667
|
-
next
|
|
668
|
-
when "/history"
|
|
669
|
-
client.history.each_with_index do |m, i|
|
|
670
|
-
puts " #{i + 1}. [#{m[:role]}] #{m[:content].to_s[0, 120]}"
|
|
671
|
-
end
|
|
672
|
-
next
|
|
673
|
-
end
|
|
674
|
-
|
|
675
|
-
begin
|
|
676
|
-
result = client.ask(line, reset: false)
|
|
677
|
-
if trace && result.tool_calls.any?
|
|
678
|
-
puts "─── tool calls ───"
|
|
679
|
-
result.tool_calls.each_with_index do |tc, i|
|
|
680
|
-
args = tc[:arguments].is_a?(Hash) ? tc[:arguments].inspect : tc[:arguments].to_s
|
|
681
|
-
puts " #{i + 1}. #{tc[:name]}(#{args})"
|
|
682
|
-
end
|
|
683
|
-
end
|
|
684
|
-
puts
|
|
685
|
-
puts result.text.to_s.empty? ? "[empty response]" : result.text
|
|
686
|
-
if trace && result.usage && result.usage.total_tokens.positive?
|
|
687
|
-
printf "[%d tokens / $%.6f this turn session: %d / $%.4f]\n",
|
|
688
|
-
result.usage.total_tokens, result.usage.cost_usd,
|
|
689
|
-
client.usage.total_tokens, client.usage.cost_usd
|
|
690
|
-
end
|
|
691
|
-
rescue Interrupt
|
|
692
|
-
puts "\n[interrupted]"
|
|
693
|
-
next
|
|
694
|
-
rescue => e
|
|
695
|
-
puts "[error] #{e.class}: #{e.message}"
|
|
696
|
-
end
|
|
697
|
-
end
|
|
698
|
-
|
|
699
|
-
puts "\nbye"
|
|
700
|
-
end
|
|
701
|
-
|
|
702
|
-
# -------------------------------------------------------------------------
|
|
703
|
-
# rake "mcp:tool[query_class,{\"class_name\":\"Song\",\"limit\":3}]"
|
|
704
|
-
#
|
|
705
|
-
# One-shot tool dispatch from the command line. The first arg is the tool
|
|
706
|
-
# name; the second is a JSON object of keyword arguments. Result printed
|
|
707
|
-
# as pretty JSON. Useful for ad-hoc smoke checks without spinning up IRB.
|
|
708
|
-
# -------------------------------------------------------------------------
|
|
709
|
-
desc "One-shot tool call: rake 'mcp:tool[name,jsonArgs]'"
|
|
710
|
-
task :tool, [:name, :args_json] do |_t, args|
|
|
711
|
-
begin
|
|
712
|
-
require "dotenv/load"
|
|
713
|
-
rescue LoadError
|
|
714
|
-
end
|
|
715
|
-
require "json"
|
|
716
|
-
require "parse-stack"
|
|
717
|
-
require "parse/agent"
|
|
718
|
-
|
|
719
|
-
tool_name = (args[:name] || abort("usage: rake 'mcp:tool[name,jsonArgs]'")).to_sym
|
|
720
|
-
raw = args[:args_json] || "{}"
|
|
721
|
-
parsed = JSON.parse(raw)
|
|
722
|
-
kwargs = parsed.transform_keys(&:to_sym)
|
|
723
|
-
|
|
724
|
-
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
|
|
725
|
-
Parse.setup(
|
|
726
|
-
server_url: server_url,
|
|
727
|
-
application_id: app_id,
|
|
728
|
-
api_key: rest_api_key,
|
|
729
|
-
master_key: master_key,
|
|
730
|
-
)
|
|
731
|
-
|
|
732
|
-
agent = Parse::Agent.new(permissions: (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym)
|
|
733
|
-
result = agent.execute(tool_name, **kwargs)
|
|
734
|
-
puts JSON.pretty_generate(result)
|
|
735
|
-
exit(result[:success] ? 0 : 1)
|
|
736
|
-
end
|
|
737
|
-
end
|
|
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
|
-
|
|
807
|
-
desc "List undocumented methods"
|
|
808
|
-
task "yard:stats" do
|
|
809
|
-
exec "yard stats --list-undoc"
|
|
810
|
-
end
|
|
811
|
-
|
|
812
|
-
desc "Start the yard server"
|
|
813
|
-
task "docs" do
|
|
814
|
-
# `-t docs/yard-template` is required: `yard server` does not honor
|
|
815
|
-
# the --template-path line in .yardopts (that's only read by
|
|
816
|
-
# `yard doc`), so the custom theme overlay only takes effect when
|
|
817
|
-
# the path is passed on the command line.
|
|
818
|
-
exec "rm -rf ./yard && yard server --reload -t docs/yard-template"
|
|
819
|
-
end
|
|
820
|
-
|
|
821
|
-
YARD::Rake::YardocTask.new do |t|
|
|
822
|
-
t.files = ["lib/**/*.rb"] # optional
|
|
823
|
-
t.options = ["-o", "doc/parse-stack-next"] # optional
|
|
824
|
-
t.stats_options = ["--list-undoc"] # optional
|
|
825
|
-
end
|