parse-stack-next 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
data/Rakefile ADDED
@@ -0,0 +1,573 @@
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:2337/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? ? "myAppId" : app_id)
27
+ rest_api_key = (rest_api_key.to_s.empty? ? "myApiKey" : rest_api_key)
28
+ master_key = (master_key.to_s.empty? ? "myMasterKey" : 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
+ # Default test task runs all tests with Docker enabled
39
+ Rake::TestTask.new do |t|
40
+ ENV['PARSE_TEST_USE_DOCKER'] = 'true'
41
+ t.libs << "lib/parse/stack"
42
+ t.test_files = FileList["test/lib/**/*_test.rb"]
43
+ t.warning = false
44
+ t.verbose = true
45
+ end
46
+
47
+ # Integration tests require Docker
48
+ namespace :test do
49
+ desc "Run all integration tests (requires Docker)"
50
+ task :integration do
51
+ integration_files = FileList["test/lib/**/*integration_test.rb"]
52
+
53
+ puts "Running #{integration_files.length} integration test files..."
54
+ integration_files.each_with_index do |file, index|
55
+ puts "Running integration test #{index + 1}/#{integration_files.length}: #{file}"
56
+
57
+ # 10: docker integration test fails for cloud functions
58
+ skip_till = 0
59
+ if (index + 1) <= skip_till
60
+ puts "Skipping test #{index + 1} as per configuration\n"
61
+ next
62
+ end
63
+
64
+ puts "\n" + "="*80
65
+ puts "Running: #{file}"
66
+ puts "="*80
67
+ system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || exit(1)
68
+ end
69
+ puts "\n✅ All integration tests completed successfully!"
70
+ end
71
+
72
+ desc "Run unit tests only (no Docker required)"
73
+ task :unit do
74
+ unit_files = FileList["test/lib/**/*_test.rb"].exclude("test/lib/**/*integration_test.rb")
75
+
76
+ puts "Running #{unit_files.length} unit test files (no Docker)..."
77
+ unit_files.each_with_index do |file, index|
78
+ puts "Running unit test #{index + 1}/#{unit_files.length}: #{file}"
79
+
80
+ # 73 is problematic Testing Contains and Nin with Parse Objects with contains and nin
81
+ skip_till = 0
82
+ if (index + 1) <= skip_till
83
+ puts "Skipping test #{index + 1} as per configuration"
84
+ next
85
+ end
86
+
87
+ system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || exit(1)
88
+ end
89
+ puts "\n✅ All unit tests completed successfully!"
90
+ end
91
+
92
+ desc "List all available test files"
93
+ task :list do
94
+ puts "\nIntegration Tests:"
95
+ FileList["test/lib/**/*integration_test.rb"].each { |f| puts " #{f}" }
96
+
97
+ puts "\nUnit Tests:"
98
+ FileList["test/lib/**/*_test.rb"].exclude("test/lib/**/*integration_test.rb").each { |f| puts " #{f}" }
99
+ end
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # MCP protocol conformance via Anthropic's official mcp-inspector tool.
103
+ #
104
+ # Boots a local MCPServer against a configured Parse Server, then runs
105
+ # @modelcontextprotocol/inspector in CLI mode to validate the MCP wire
106
+ # protocol (initialize handshake, tools/list, tools/call, prompts/list,
107
+ # resources/list, error envelopes). Catches protocol regressions that
108
+ # in-process integration tests can miss because they exercise the Ruby
109
+ # call surface, not the JSON wire format an external MCP client sees.
110
+ #
111
+ # Requirements:
112
+ # - npx on PATH (Node.js 18+)
113
+ # - A running Parse Server (e.g., `docker-compose -f scripts/docker/
114
+ # docker-compose.test.yml up -d`)
115
+ # - Env: PARSE_SERVER_URL, PARSE_APP_ID, PARSE_API_KEY (defaults match
116
+ # the Docker compose setup in scripts/docker/docker-compose.test.yml)
117
+ #
118
+ # Usage:
119
+ # rake test:mcp_inspector
120
+ # rake test:mcp_inspector METHOD=tools/list # override target method
121
+ # ---------------------------------------------------------------------------
122
+ desc "Validate MCP protocol with Anthropic's mcp-inspector (requires npx)"
123
+ task :mcp_inspector do
124
+ require "net/http"
125
+ require "uri"
126
+ require "fileutils"
127
+
128
+ unless system("which npx > /dev/null 2>&1")
129
+ abort "[mcp_inspector] npx not found on PATH. Install Node.js 18+ or use `nvm use 18`."
130
+ end
131
+
132
+ port = ENV["MCP_INSPECTOR_PORT"] || "3099"
133
+ api_key = ENV["MCP_INSPECTOR_KEY"] || "rake-inspector-key"
134
+ method = ENV["METHOD"] || "tools/list"
135
+
136
+ server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
137
+
138
+ boot = <<~RUBY
139
+ $LOAD_PATH.unshift(File.expand_path('lib'))
140
+ require 'parse-stack'
141
+ Parse.setup(
142
+ server_url: #{server_url.inspect},
143
+ application_id: #{app_id.inspect},
144
+ api_key: #{rest_api_key.inspect},
145
+ master_key: #{master_key.inspect},
146
+ )
147
+ ENV['PARSE_MCP_ENABLED'] = 'true'
148
+ Parse.mcp_server_enabled = true
149
+ Parse::Agent.mcp_enabled = true
150
+ require 'parse/agent/mcp_server'
151
+ Parse::Agent::MCPServer.run(
152
+ port: #{port.to_i},
153
+ host: '127.0.0.1',
154
+ permissions: :readonly,
155
+ api_key: #{api_key.inspect},
156
+ )
157
+ RUBY
158
+
159
+ log_path = "tmp/mcp-inspector-server.log"
160
+ FileUtils.mkdir_p("tmp")
161
+ pid = Process.spawn("ruby", "-e", boot, out: log_path, err: log_path)
162
+
163
+ begin
164
+ ready = false
165
+ 40.times do
166
+ sleep 0.25
167
+ begin
168
+ uri = URI("http://127.0.0.1:#{port}/health")
169
+ ready = (Net::HTTP.get_response(uri).code == "200")
170
+ break if ready
171
+ rescue Errno::ECONNREFUSED, Errno::EADDRINUSE
172
+ # retry
173
+ end
174
+ end
175
+ unless ready
176
+ warn "[mcp_inspector] MCPServer failed to become healthy on port #{port}. Server log:"
177
+ warn(File.read(log_path)) rescue nil
178
+ abort "[mcp_inspector] aborting"
179
+ end
180
+ puts "[mcp_inspector] MCPServer healthy on http://127.0.0.1:#{port}"
181
+
182
+ cmd = [
183
+ "npx", "--yes", "@modelcontextprotocol/inspector",
184
+ "--cli", "http://127.0.0.1:#{port}/mcp",
185
+ "--method", method,
186
+ "--header", "X-MCP-API-Key:#{api_key}",
187
+ ]
188
+ puts "[mcp_inspector] $ #{cmd.join(" ")}"
189
+ ok = system(*cmd)
190
+ abort "[mcp_inspector] inspector exited non-zero" unless ok
191
+ puts "[mcp_inspector] protocol check passed"
192
+ ensure
193
+ if pid
194
+ Process.kill("TERM", pid) rescue nil
195
+ Process.wait(pid) rescue nil
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ task :default => :test
202
+
203
+ task :console do
204
+ exec "./bin/console"
205
+ end
206
+ task :c => :console
207
+
208
+ # ===========================================================================
209
+ # MCP namespace: interactive REPL and one-shot tool dispatch.
210
+ # ===========================================================================
211
+ namespace :mcp do
212
+ # -------------------------------------------------------------------------
213
+ # rake mcp:console
214
+ #
215
+ # Drops you into an IRB session with a pre-configured Parse::Agent and
216
+ # MCP helpers bound at the top level. Talk to the agent the same way an
217
+ # LLM would, but interactively from your terminal.
218
+ #
219
+ # Setup:
220
+ # - .env (or shell env) provides PARSE_SERVER_URL / PARSE_APP_ID /
221
+ # PARSE_API_KEY / PARSE_MASTER_KEY. Defaults match the Docker
222
+ # compose harness in scripts/docker/docker-compose.test.yml.
223
+ # - Optionally MCP_AGENT_PERMISSIONS=readonly|write|admin
224
+ # (default :readonly).
225
+ #
226
+ # Bindings available in the REPL:
227
+ # agent — the Parse::Agent instance.
228
+ # tools — print every tool the agent has access to.
229
+ # schemas — print every visible class name.
230
+ # t(name, **kwargs) — invoke a tool, return its result hash.
231
+ # q(class_name, ...) — shortcut for t(:query_class, class_name:, **opts).
232
+ # count(class_name) — shortcut for t(:count_objects, class_name:, ...).
233
+ # schema(class_name) — shortcut for t(:get_schema, class_name:).
234
+ # dispatch(method, params={}) — call MCPDispatcher.call(body:, agent:).
235
+ # prompts — print every registered + builtin prompt.
236
+ # render_prompt(name, args={}) — render a prompt to its message envelope.
237
+ #
238
+ # Example session:
239
+ # $ bundle exec rake mcp:console
240
+ # irb> tools
241
+ # irb> q("MCPSchoolTeacher", limit: 3)
242
+ # irb> count("MCPSchoolStudent")
243
+ # irb> dispatch("initialize")
244
+ # -------------------------------------------------------------------------
245
+ desc "Interactive MCP REPL: query a Parse::Agent like an LLM would, but with Ruby"
246
+ task :console do
247
+ require "irb"
248
+ require "json"
249
+ # dotenv is in the Gemfile :test, :development group; load .env if present.
250
+ begin
251
+ require "dotenv/load"
252
+ rescue LoadError
253
+ # dotenv not installed; rely on shell env vars
254
+ end
255
+
256
+ $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
257
+ require "parse-stack"
258
+ require "parse/agent"
259
+ require "parse/agent/mcp_dispatcher"
260
+ require "parse/agent/prompts"
261
+ require "parse/agent/mcp_client"
262
+
263
+ server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
264
+ permissions = (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym
265
+
266
+ Parse.setup(
267
+ server_url: server_url,
268
+ application_id: app_id,
269
+ api_key: rest_api_key,
270
+ master_key: master_key,
271
+ )
272
+
273
+ agent = Parse::Agent.new(permissions: permissions)
274
+
275
+ # Bind helpers as singleton methods on TOPLEVEL_BINDING so they're
276
+ # callable bare in the IRB session without a receiver.
277
+ Object.send(:define_method, :agent) { agent }
278
+ Object.send(:define_method, :_mcp_agent_const) { agent }
279
+
280
+ Object.send(:define_method, :tools) do
281
+ list = agent.tool_definitions(format: :mcp).map { |t| t[:name] || t["name"] }
282
+ puts list.sort.join("\n")
283
+ list.size
284
+ end
285
+
286
+ Object.send(:define_method, :schemas) do
287
+ result = agent.execute(:get_all_schemas)
288
+ unless result[:success]
289
+ puts "get_all_schemas failed: #{result[:error]}"
290
+ next nil
291
+ end
292
+ custom = (result[:data][:custom] || []).map { |c| c[:name] }
293
+ built_in = (result[:data][:built_in] || []).map { |c| c[:name] }
294
+ puts "Custom: #{custom.sort.join(", ")}"
295
+ puts "Built-in: #{built_in.sort.join(", ")}"
296
+ custom + built_in
297
+ end
298
+
299
+ Object.send(:define_method, :t) do |name, **kwargs|
300
+ agent.execute(name.to_sym, **kwargs)
301
+ end
302
+
303
+ Object.send(:define_method, :q) do |class_name, **opts|
304
+ t(:query_class, class_name: class_name, **opts)
305
+ end
306
+
307
+ Object.send(:define_method, :count) do |class_name, **opts|
308
+ t(:count_objects, class_name: class_name, **opts)
309
+ end
310
+
311
+ Object.send(:define_method, :schema) do |class_name|
312
+ t(:get_schema, class_name: class_name)
313
+ end
314
+
315
+ Object.send(:define_method, :dispatch) do |method, params = {}|
316
+ body = { "jsonrpc" => "2.0", "id" => SecureRandom.hex(4), "method" => method.to_s, "params" => params }
317
+ Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
318
+ end
319
+
320
+ Object.send(:define_method, :prompts) do
321
+ list = Parse::Agent::Prompts.list.map { |p| p["name"] }
322
+ puts list.sort.join("\n")
323
+ list.size
324
+ end
325
+
326
+ Object.send(:define_method, :render_prompt) do |name, args = {}|
327
+ Parse::Agent::Prompts.render(name.to_s, args.transform_keys(&:to_s))
328
+ end
329
+
330
+ # When LLM_PROVIDER + LLM_API_KEY are in env (e.g. via .env), bind
331
+ # `mcp` as a conversational client. Lets you do:
332
+ # mcp.ask("how many students?")
333
+ # _.reply("just for Ms. Vasquez")
334
+ mcp = nil
335
+ if ENV["LLM_PROVIDER"]
336
+ begin
337
+ mcp = Parse::Agent::MCPClient.new(agent: agent)
338
+ Object.send(:define_method, :mcp) { mcp }
339
+ rescue ArgumentError => e
340
+ puts "[mcp:console] could not initialize MCPClient — #{e.message}"
341
+ puts "[mcp:console] set LLM_PROVIDER + LLM_API_KEY in your .env (see .env.sample)"
342
+ end
343
+ end
344
+
345
+ puts "=" * 70
346
+ puts "Parse::Agent MCP Console"
347
+ puts "=" * 70
348
+ puts "Server: #{server_url}"
349
+ puts "Permissions: #{permissions}"
350
+ puts "Agent: #{agent.class.name} (#{agent.allowed_tools.size} tools)"
351
+ puts "LLM client: " + (mcp ? "#{mcp.provider} / #{mcp.model}" : "DISABLED (set LLM_PROVIDER + LLM_API_KEY to enable mcp.ask)")
352
+ puts
353
+ puts "Try:"
354
+ if mcp
355
+ puts " mcp.ask('how many students do we have?')"
356
+ puts " _.reply('what about just for Ms. Vasquez?') # chain replies"
357
+ puts
358
+ end
359
+ puts " tools # list available tools"
360
+ puts " schemas # list visible Parse classes"
361
+ puts " q('User', limit: 3) # query_class shortcut"
362
+ puts " count('Song') # count_objects shortcut"
363
+ puts " schema('Song') # get_schema shortcut"
364
+ puts " t(:query_class, class_name: 'Song', where: { name: 'X' })"
365
+ puts " dispatch('tools/list') # MCPDispatcher round-trip"
366
+ puts " prompts # list registered prompts"
367
+ puts " render_prompt('parse_conventions')"
368
+ puts "=" * 70
369
+
370
+ IRB.start
371
+ end
372
+
373
+ # -------------------------------------------------------------------------
374
+ # rake mcp:chat
375
+ #
376
+ # Conversational CLI loop — talk to your Parse database via the MCP agent
377
+ # in plain English. Each turn drives the LLM through tool calls and prints
378
+ # the final answer; context persists across turns. Like a tiny REPL just
379
+ # for the MCP agent.
380
+ #
381
+ # Setup:
382
+ # - .env (or shell env) with LLM_PROVIDER + LLM_API_KEY (see .env.sample)
383
+ # - PARSE_SERVER_URL / PARSE_APP_ID / PARSE_API_KEY / PARSE_MASTER_KEY
384
+ # (defaults match the Docker compose harness)
385
+ #
386
+ # Slash commands inside the loop:
387
+ # /reset — start a fresh conversation (clear history)
388
+ # /compact — replace history with an LLM-generated summary (1 extra call)
389
+ # /tools — list available MCP tools
390
+ # /trace — toggle tool-call tracing on/off
391
+ # /cost — show running token + USD cost totals
392
+ # /history — print conversation history
393
+ # /exit — leave the chat (also: /quit, exit, quit, Ctrl-D, empty line)
394
+ # -------------------------------------------------------------------------
395
+ desc "Conversational CLI: talk to your Parse data via the MCP agent"
396
+ task :chat do
397
+ begin
398
+ require "dotenv/load"
399
+ rescue LoadError
400
+ end
401
+
402
+ $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
403
+ require "parse-stack"
404
+ require "parse/agent"
405
+ require "parse/agent/mcp_client"
406
+
407
+ unless ENV["LLM_PROVIDER"]
408
+ abort "[mcp:chat] LLM_PROVIDER is not set. Add it to .env (see .env.sample). " \
409
+ "Supported providers: openai, anthropic, lmstudio."
410
+ end
411
+
412
+ server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
413
+ Parse.setup(
414
+ server_url: server_url,
415
+ application_id: app_id,
416
+ api_key: rest_api_key,
417
+ master_key: master_key,
418
+ )
419
+
420
+ permissions = (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym
421
+ agent = Parse::Agent.new(permissions: permissions)
422
+ client = Parse::Agent::MCPClient.new(agent: agent)
423
+ trace = (ENV["MCP_CHAT_TRACE"] || "false") == "true"
424
+
425
+ slash_help = lambda do
426
+ puts "Slash commands:"
427
+ puts " /help — print this list"
428
+ puts " /reset — clear conversation history"
429
+ puts " /compact — replace history with an LLM-generated summary"
430
+ puts " /tools — list MCP tools the agent has access to"
431
+ puts " /trace — toggle per-turn tool-call tracing on/off"
432
+ puts " /cost — show running token + USD totals (and last turn)"
433
+ puts " /history — print the conversation log"
434
+ puts " /exit — leave (also /quit, exit, quit, Ctrl-D, empty line)"
435
+ end
436
+
437
+ puts "=" * 70
438
+ puts "Parse MCP Chat — #{client.provider} / #{client.model}"
439
+ puts "Permissions: #{permissions} | Trace: #{trace ? "on" : "off"}"
440
+ puts "Type your question. Type /help for slash commands."
441
+ puts "=" * 70
442
+
443
+ loop do
444
+ print "\n> "
445
+ line = $stdin.gets
446
+ break if line.nil? # Ctrl-D
447
+ line = line.strip
448
+ next if line.empty?
449
+
450
+ case line
451
+ when "/exit", "/quit", "exit", "quit"
452
+ break
453
+ when "/help"
454
+ slash_help.call
455
+ next
456
+ when "/reset"
457
+ client.reset!
458
+ puts "[conversation cleared]"
459
+ next
460
+ when "/compact"
461
+ before = client.usage.total_tokens
462
+ summary = client.compact!
463
+ if summary.empty?
464
+ puts "[nothing to compact]"
465
+ else
466
+ delta = client.usage.total_tokens - before
467
+ puts "[compacted; +#{delta} tokens spent on summary]"
468
+ puts " summary: #{summary[0, 200]}#{summary.length > 200 ? "…" : ""}"
469
+ end
470
+ next
471
+ when "/tools"
472
+ puts agent.tool_definitions(format: :mcp).map { |t| t[:name] || t["name"] }.sort.join("\n")
473
+ next
474
+ when "/trace"
475
+ trace = !trace
476
+ puts "[trace #{trace ? "on" : "off"}]"
477
+ next
478
+ when "/cost"
479
+ u = client.usage
480
+ last = client.last_call_usage
481
+ printf " session: %d in + %d out = %d tokens $%.4f\n",
482
+ u.prompt_tokens, u.completion_tokens, u.total_tokens, u.cost_usd
483
+ if last
484
+ printf " last: %d in + %d out = %d tokens $%.6f\n",
485
+ last.prompt_tokens, last.completion_tokens, last.total_tokens, last.cost_usd
486
+ end
487
+ next
488
+ when "/history"
489
+ client.history.each_with_index do |m, i|
490
+ puts " #{i + 1}. [#{m[:role]}] #{m[:content].to_s[0, 120]}"
491
+ end
492
+ next
493
+ end
494
+
495
+ begin
496
+ result = client.ask(line, reset: false)
497
+ if trace && result.tool_calls.any?
498
+ puts "─── tool calls ───"
499
+ result.tool_calls.each_with_index do |tc, i|
500
+ args = tc[:arguments].is_a?(Hash) ? tc[:arguments].inspect : tc[:arguments].to_s
501
+ puts " #{i + 1}. #{tc[:name]}(#{args})"
502
+ end
503
+ end
504
+ puts
505
+ puts result.text.to_s.empty? ? "[empty response]" : result.text
506
+ if trace && result.usage && result.usage.total_tokens.positive?
507
+ printf "[%d tokens / $%.6f this turn session: %d / $%.4f]\n",
508
+ result.usage.total_tokens, result.usage.cost_usd,
509
+ client.usage.total_tokens, client.usage.cost_usd
510
+ end
511
+ rescue Interrupt
512
+ puts "\n[interrupted]"
513
+ next
514
+ rescue => e
515
+ puts "[error] #{e.class}: #{e.message}"
516
+ end
517
+ end
518
+
519
+ puts "\nbye"
520
+ end
521
+
522
+ # -------------------------------------------------------------------------
523
+ # rake "mcp:tool[query_class,{\"class_name\":\"Song\",\"limit\":3}]"
524
+ #
525
+ # One-shot tool dispatch from the command line. The first arg is the tool
526
+ # name; the second is a JSON object of keyword arguments. Result printed
527
+ # as pretty JSON. Useful for ad-hoc smoke checks without spinning up IRB.
528
+ # -------------------------------------------------------------------------
529
+ desc "One-shot tool call: rake 'mcp:tool[name,jsonArgs]'"
530
+ task :tool, [:name, :args_json] do |_t, args|
531
+ begin
532
+ require "dotenv/load"
533
+ rescue LoadError
534
+ end
535
+ require "json"
536
+ require "parse-stack"
537
+ require "parse/agent"
538
+
539
+ tool_name = (args[:name] || abort("usage: rake 'mcp:tool[name,jsonArgs]'")).to_sym
540
+ raw = args[:args_json] || "{}"
541
+ parsed = JSON.parse(raw)
542
+ kwargs = parsed.transform_keys(&:to_sym)
543
+
544
+ server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
545
+ Parse.setup(
546
+ server_url: server_url,
547
+ application_id: app_id,
548
+ api_key: rest_api_key,
549
+ master_key: master_key,
550
+ )
551
+
552
+ agent = Parse::Agent.new(permissions: (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym)
553
+ result = agent.execute(tool_name, **kwargs)
554
+ puts JSON.pretty_generate(result)
555
+ exit(result[:success] ? 0 : 1)
556
+ end
557
+ end
558
+
559
+ desc "List undocumented methods"
560
+ task "yard:stats" do
561
+ exec "yard stats --list-undoc"
562
+ end
563
+
564
+ desc "Start the yard server"
565
+ task "docs" do
566
+ exec "rm -rf ./yard && yard server --reload"
567
+ end
568
+
569
+ YARD::Rake::YardocTask.new do |t|
570
+ t.files = ["lib/**/*.rb"] # optional
571
+ t.options = ["-o", "doc/parse-stack"] # optional
572
+ t.stats_options = ["--list-undoc"] # optional
573
+ end
data/bin/console ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require 'debug/prelude'
5
+ require 'dotenv'
6
+ require "parse/stack"
7
+
8
+ Dotenv.load
9
+
10
+ def setup
11
+ Parse.setup # cache: 'redis://localhost:6379'
12
+
13
+ puts "[ParseServerURL] #{Parse.client.server_url}"
14
+ puts "[ParseAppID] #{Parse.client.app_id}"
15
+
16
+ if Parse.client.master_key.present?
17
+ Parse.auto_generate_models!.each do |model|
18
+ puts "Generated #{model}"
19
+ end
20
+ end
21
+
22
+ end
23
+ puts "Type 'setup' to connect to Parse-Server"
24
+
25
+ # Create shortnames
26
+ Parse.use_shortnames!
27
+
28
+ # You can add fixtures and/or initialization code here to make experimenting
29
+ # with your gem easier. You can also use a different console, if you like.
30
+
31
+ # (If you use this, don't forget to add pry to your Gemfile!)
32
+ require "pry"
33
+ Pry.start
34
+
35
+ #
36
+ # require "irb"
37
+ # IRB.start
38
+ #Rack::Server.start :app => HelloWorldApp