parse-stack-next 5.5.3 → 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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -4
  3. data/README.md +26 -13
  4. data/bin/parse-console +9 -1
  5. data/docs/TEST_SERVER.md +115 -238
  6. data/docs/mcp_guide.md +1 -1
  7. data/docs/mongodb_index_optimization_guide.md +3 -2
  8. data/docs/usage_guide.md +1 -1
  9. data/docs/yard-template/default/fulldoc/html/css/common.css +52 -9
  10. data/docs/yard-template/default/fulldoc/html/css/full_list.css +40 -13
  11. data/lib/parse/agent/constraint_translator.rb +18 -18
  12. data/lib/parse/agent/errors.rb +29 -7
  13. data/lib/parse/agent/metadata_dsl.rb +6 -6
  14. data/lib/parse/agent/tools.rb +250 -59
  15. data/lib/parse/agent.rb +42 -30
  16. data/lib/parse/api/aggregate.rb +3 -3
  17. data/lib/parse/api/cloud_functions.rb +19 -10
  18. data/lib/parse/api/objects.rb +8 -8
  19. data/lib/parse/api/users.rb +9 -9
  20. data/lib/parse/atlas_search/session.rb +34 -34
  21. data/lib/parse/atlas_search.rb +243 -110
  22. data/lib/parse/client/body_builder.rb +10 -10
  23. data/lib/parse/client/logging.rb +5 -2
  24. data/lib/parse/client/profiling.rb +5 -2
  25. data/lib/parse/client/protocol.rb +1 -1
  26. data/lib/parse/client/url_redaction.rb +94 -0
  27. data/lib/parse/client.rb +43 -28
  28. data/lib/parse/embeddings/image_fetch.rb +6 -1
  29. data/lib/parse/embeddings/voyage.rb +16 -17
  30. data/lib/parse/live_query/client.rb +7 -7
  31. data/lib/parse/live_query/subscription.rb +1 -1
  32. data/lib/parse/lock.rb +1 -1
  33. data/lib/parse/lock_backend.rb +118 -2
  34. data/lib/parse/model/acl.rb +24 -24
  35. data/lib/parse/model/classes/job_schedule.rb +8 -8
  36. data/lib/parse/model/classes/job_status.rb +9 -9
  37. data/lib/parse/model/classes/role.rb +49 -49
  38. data/lib/parse/model/classes/session.rb +2 -2
  39. data/lib/parse/model/classes/user.rb +66 -66
  40. data/lib/parse/model/core/builder.rb +7 -7
  41. data/lib/parse/model/core/create_lock.rb +1 -1
  42. data/lib/parse/model/core/properties.rb +4 -4
  43. data/lib/parse/model/file.rb +57 -16
  44. data/lib/parse/model/model.rb +19 -19
  45. data/lib/parse/model/object.rb +38 -38
  46. data/lib/parse/model/pointer.rb +4 -4
  47. data/lib/parse/model/push.rb +5 -5
  48. data/lib/parse/mongodb.rb +84 -26
  49. data/lib/parse/pipeline_security.rb +2 -2
  50. data/lib/parse/query/constraints.rb +38 -38
  51. data/lib/parse/query.rb +151 -75
  52. data/lib/parse/retrieval/reranker/cohere.rb +30 -0
  53. data/lib/parse/schema.rb +1 -1
  54. data/lib/parse/stack/version.rb +1 -1
  55. data/lib/parse/stack.rb +23 -10
  56. data/lib/parse/two_factor_auth/user_extension.rb +25 -25
  57. data/lib/parse/webhooks/payload.rb +35 -35
  58. data/lib/parse/webhooks/registration.rb +2 -2
  59. data/lib/parse/webhooks/replay_protection.rb +16 -16
  60. data/lib/parse/webhooks.rb +11 -11
  61. data/parse-stack-next.gemspec +19 -1
  62. metadata +2 -38
  63. data/.bundle/config +0 -5
  64. data/.env.sample +0 -138
  65. data/.env.test +0 -10
  66. data/.github/ISSUE_TEMPLATE/bug_report.yml +0 -105
  67. data/.github/ISSUE_TEMPLATE/feature_request.yml +0 -67
  68. data/.github/dependabot.yml +0 -13
  69. data/.github/workflows/codeql.yml +0 -44
  70. data/.github/workflows/docs.yml +0 -39
  71. data/.github/workflows/release.yml +0 -43
  72. data/.github/workflows/ruby.yml +0 -38
  73. data/.gitignore +0 -56
  74. data/.ruby-version +0 -1
  75. data/.solargraph.yml +0 -22
  76. data/.vscode/settings.json +0 -3
  77. data/.yardopts +0 -19
  78. data/Gemfile +0 -43
  79. data/Gemfile.lock +0 -198
  80. data/Makefile +0 -63
  81. data/Rakefile +0 -825
  82. data/config/parse-config.json +0 -12
  83. data/scripts/debug-ips.js +0 -35
  84. data/scripts/docker/Dockerfile.parse +0 -17
  85. data/scripts/docker/atlas-init.js +0 -284
  86. data/scripts/docker/docker-compose.atlas.yml +0 -80
  87. data/scripts/docker/docker-compose.test.yml +0 -159
  88. data/scripts/docker/docker-compose.verifyemail.yml +0 -4
  89. data/scripts/docker/mongo-init.js +0 -21
  90. data/scripts/docker/preflight.sh +0 -76
  91. data/scripts/eval_mcp_with_lm_studio.rb +0 -274
  92. data/scripts/start-parse.sh +0 -154
  93. data/scripts/start_mcp_server.rb +0 -78
  94. data/scripts/test_server_connection.rb +0 -82
  95. data/scripts/vector_prototype/create_vector_index.js +0 -105
  96. data/scripts/vector_prototype/fetch_embeddings.py +0 -241
  97. data/scripts/vector_prototype/fixture_manifest.json +0 -9
  98. data/scripts/vector_prototype/query_prototype.rb +0 -84
  99. 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