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
@@ -0,0 +1,82 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../pipeline_security"
5
+
6
+ module Parse
7
+ class Agent
8
+ # Validates MongoDB aggregation pipelines to prevent security vulnerabilities.
9
+ #
10
+ # Thin compatibility wrapper around {Parse::PipelineSecurity}. The
11
+ # actual stage allowlist, operator denylist, depth cap, and recursive
12
+ # walk live there; this module preserves the `Parse::Agent::PipelineValidator.validate!`
13
+ # entry point and the `PipelineSecurityError` exception class for
14
+ # callers that pin to them.
15
+ #
16
+ # @example
17
+ # Parse::Agent::PipelineValidator.validate!([
18
+ # { "$match" => { "status" => "active" } },
19
+ # { "$group" => { "_id" => "$category", "count" => { "$sum" => 1 } } }
20
+ # ])
21
+ # # => true
22
+ #
23
+ # Parse::Agent::PipelineValidator.validate!([{ "$out" => "hacked" }])
24
+ # # => raises PipelineSecurityError
25
+ #
26
+ module PipelineValidator
27
+ extend self
28
+
29
+ # Security error for blocked or dangerous pipeline operations.
30
+ # Wraps the unified {Parse::PipelineSecurity::Error} for callers
31
+ # that have rescued this class specifically.
32
+ class PipelineSecurityError < SecurityError
33
+ attr_reader :stage, :reason, :operator
34
+
35
+ def initialize(message, stage: nil, reason: nil, operator: nil)
36
+ @stage = stage
37
+ @reason = reason
38
+ @operator = operator
39
+ super(message)
40
+ end
41
+ end
42
+
43
+ # Mirrors of the canonical constants in {Parse::PipelineSecurity},
44
+ # preserved as constants here so external callers reading
45
+ # `Parse::Agent::PipelineValidator::BLOCKED_STAGES` continue to work.
46
+ BLOCKED_STAGES = Parse::PipelineSecurity::DENIED_OPERATORS
47
+ ALLOWED_STAGES = Parse::PipelineSecurity::ALLOWED_STAGES
48
+ MAX_PIPELINE_DEPTH = Parse::PipelineSecurity::MAX_DEPTH
49
+ MAX_STAGES = Parse::PipelineSecurity::MAX_PIPELINE_STAGES
50
+
51
+ # Validate an aggregation pipeline for security issues.
52
+ # Delegates to {Parse::PipelineSecurity.validate_pipeline!} and
53
+ # translates its error into {PipelineSecurityError} for backwards
54
+ # compatibility.
55
+ #
56
+ # @param pipeline [Array<Hash>] the aggregation pipeline stages
57
+ # @raise [PipelineSecurityError] if pipeline contains blocked or unknown stages
58
+ # @return [true] if pipeline is valid
59
+ def validate!(pipeline)
60
+ Parse::PipelineSecurity.validate_pipeline!(pipeline)
61
+ rescue Parse::PipelineSecurity::Error => e
62
+ raise PipelineSecurityError.new(
63
+ e.message,
64
+ stage: e.stage,
65
+ reason: e.reason,
66
+ operator: e.operator,
67
+ )
68
+ end
69
+
70
+ # Check if a pipeline is valid without raising.
71
+ #
72
+ # @param pipeline [Array<Hash>] the aggregation pipeline
73
+ # @return [Boolean] true if valid, false otherwise
74
+ def valid?(pipeline)
75
+ validate!(pipeline)
76
+ true
77
+ rescue PipelineSecurityError
78
+ false
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,351 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "thread"
5
+ require_relative "errors"
6
+
7
+ module Parse
8
+ class Agent
9
+ # Standalone prompt catalog and renderer for the MCP prompts layer.
10
+ #
11
+ # This module can be loaded independently of the WEBrick MCPServer.
12
+ # All references to Parse::Agent::PARSE_CONVENTIONS and
13
+ # Parse::Agent::RelationGraph are resolved at call-time (inside lambda
14
+ # bodies), so the file remains loadable standalone as long as those
15
+ # constants exist by the time render() is invoked.
16
+ #
17
+ # == Extension API
18
+ #
19
+ # Third-party apps may register custom prompts:
20
+ #
21
+ # Parse::Agent::Prompts.register(
22
+ # name: "my_prompt",
23
+ # description: "Does something useful",
24
+ # arguments: [{ "name" => "id", "description" => "Object ID", "required" => true }],
25
+ # renderer: ->(args) { "Do the thing with #{args['id']}" }
26
+ # )
27
+ #
28
+ # A renderer lambda may return either:
29
+ # - A String — used directly as the message text; description defaults to
30
+ # "Parse analytics prompt: <name>".
31
+ # - A Hash with :description and :text keys — both are used verbatim in the
32
+ # MCP response.
33
+ #
34
+ # Registering a name that matches a builtin replaces the builtin in responses.
35
+ # Call reset_registry! to restore builtins-only state (useful in tests).
36
+ #
37
+ module Prompts
38
+ # -----------------------------------------------------------------------
39
+ # Validators (verbatim from Parse::Agent::MCPServer private methods)
40
+ # -----------------------------------------------------------------------
41
+ module Validators
42
+ # Parse identifier shape (matches Parse class & field names).
43
+ IDENTIFIER_RE = /\A[A-Za-z_][A-Za-z0-9_]{0,127}\z/.freeze
44
+ # Parse objectId shape — alphanumeric, typically 10-32 chars.
45
+ OBJECT_ID_RE = /\A[A-Za-z0-9]{1,32}\z/.freeze
46
+
47
+ # @raise [Parse::Agent::ValidationError] if value is nil/empty or doesn't match the identifier pattern.
48
+ # @return [String] the validated value
49
+ def self.validate_identifier!(value, name)
50
+ raise Parse::Agent::ValidationError, "missing required argument: #{name}" if value.nil? || value.to_s.empty?
51
+ s = value.to_s
52
+ return s if s.match?(IDENTIFIER_RE)
53
+ raise Parse::Agent::ValidationError, "#{name} must match #{IDENTIFIER_RE.source} (got: #{s.inspect})"
54
+ end
55
+
56
+ # @raise [Parse::Agent::ValidationError] if value is nil/empty or doesn't match alphanumeric objectId.
57
+ # @return [String] the validated value
58
+ def self.validate_object_id!(value, name)
59
+ raise Parse::Agent::ValidationError, "missing required argument: #{name}" if value.nil? || value.to_s.empty?
60
+ s = value.to_s
61
+ return s if s.match?(OBJECT_ID_RE)
62
+ raise Parse::Agent::ValidationError, "#{name} must be an alphanumeric objectId (got: #{s.inspect})"
63
+ end
64
+
65
+ # @raise [Parse::Agent::ValidationError] if required and value is nil/empty, or if value is not valid ISO8601.
66
+ # @return [String, nil] the normalised ISO8601 string, or nil when not required and absent
67
+ def self.validate_iso8601!(value, name, required: true)
68
+ if value.nil? || value.to_s.empty?
69
+ return nil unless required
70
+ raise Parse::Agent::ValidationError, "missing required argument: #{name}"
71
+ end
72
+ require "time"
73
+ Time.iso8601(value.to_s).utc.iso8601(3)
74
+ rescue ArgumentError
75
+ raise Parse::Agent::ValidationError, "#{name} must be a valid ISO8601 timestamp (got: #{value.inspect})"
76
+ end
77
+ end
78
+
79
+ # -----------------------------------------------------------------------
80
+ # Built-in prompt catalog (string keys so list/render work in pure Ruby).
81
+ # -----------------------------------------------------------------------
82
+ BUILTIN_PROMPTS = [
83
+ {
84
+ "name" => "parse_conventions",
85
+ "description" => "Generic Parse platform conventions (objectId, createdAt, pointer/date shapes, _User, ACL). Fetch once and prepend to your system message.",
86
+ "arguments" => [],
87
+ },
88
+ {
89
+ "name" => "parse_relations",
90
+ "description" => "Compact ASCII diagram of class relationships derived from belongs_to and has_many :through => :relation. Pass `classes` for a subset slice (both endpoints must be in the set).",
91
+ "arguments" => [
92
+ { "name" => "classes", "description" => "Optional comma-separated subset, e.g. \"_User,Post,Company\"", "required" => false },
93
+ ],
94
+ },
95
+ {
96
+ "name" => "explore_database",
97
+ "description" => "Survey all Parse classes: list them, count each, and summarize what each appears to store",
98
+ "arguments" => [],
99
+ },
100
+ {
101
+ "name" => "class_overview",
102
+ "description" => "Describe a class in detail: schema, total count, and a few sample objects",
103
+ "arguments" => [
104
+ { "name" => "class_name", "description" => "Parse class name", "required" => true },
105
+ ],
106
+ },
107
+ {
108
+ "name" => "count_by",
109
+ "description" => "Count objects in a class grouped by a field (e.g. users by team, projects by status)",
110
+ "arguments" => [
111
+ { "name" => "class_name", "description" => "Parse class to count", "required" => true },
112
+ { "name" => "group_by", "description" => "Field to group by", "required" => true },
113
+ ],
114
+ },
115
+ {
116
+ "name" => "recent_activity",
117
+ "description" => "Show the most recently created objects in a class (answers \"when was the last X created\")",
118
+ "arguments" => [
119
+ { "name" => "class_name", "description" => "Parse class name", "required" => true },
120
+ { "name" => "limit", "description" => "Number of objects to return (default 10)", "required" => false },
121
+ ],
122
+ },
123
+ {
124
+ "name" => "find_relationship",
125
+ "description" => "Find objects in one class related to a given object in another (e.g. members of a team)",
126
+ "arguments" => [
127
+ { "name" => "parent_class", "description" => "Class of the parent object (e.g. Team)", "required" => true },
128
+ { "name" => "parent_id", "description" => "objectId of the parent", "required" => true },
129
+ { "name" => "child_class", "description" => "Class to query (e.g. _User)", "required" => true },
130
+ { "name" => "pointer_field", "description" => "Field on child_class that points to parent (e.g. team)", "required" => true },
131
+ ],
132
+ },
133
+ {
134
+ "name" => "created_in_range",
135
+ "description" => "Count and sample objects created within a date range",
136
+ "arguments" => [
137
+ { "name" => "class_name", "description" => "Parse class name", "required" => true },
138
+ { "name" => "since", "description" => "ISO8601 lower bound (inclusive)", "required" => true },
139
+ { "name" => "until", "description" => "ISO8601 upper bound (exclusive); omit for now", "required" => false },
140
+ ],
141
+ },
142
+ ].freeze
143
+
144
+ # -----------------------------------------------------------------------
145
+ # Builtin renderers — each lambda takes the args Hash and returns a String.
146
+ # References to Parse::Agent constants are resolved at call-time.
147
+ # -----------------------------------------------------------------------
148
+ BUILTIN_RENDERERS = {
149
+ "parse_conventions" => ->(args) {
150
+ Parse::Agent::PARSE_CONVENTIONS
151
+ },
152
+
153
+ "parse_relations" => ->(args) {
154
+ subset = args["classes"].to_s.split(",").map(&:strip).reject(&:empty?)
155
+ subset.each { |c| Validators.validate_identifier!(c, "classes entry") }
156
+ subset = nil if subset.empty?
157
+ edges = Parse::Agent::RelationGraph.build(classes: subset)
158
+ diagram = Parse::Agent::RelationGraph.to_ascii(edges)
159
+ slice_note = subset ? " (subset: #{subset.join(", ")})" : ""
160
+ empty_subset_hint = (subset && edges.empty?) ?
161
+ " No edges matched the requested subset — check the class names for casing and spelling (e.g. `_User`, not `_user`)." : ""
162
+ "Class relationships in this Parse database#{slice_note}.#{empty_subset_hint} " \
163
+ "Owning-field names are camelCase exactly as stored in Parse. " \
164
+ "Read each line as: <one side> ─<cardinality>→ <many side> (owning field). " \
165
+ "Use the owning field name with `query_class where:` to filter by that pointer, or with `include:` to expand it.\n\n#{diagram}"
166
+ },
167
+
168
+ "explore_database" => ->(args) {
169
+ "Survey the Parse database. Call get_all_schemas to list every class, then call count_objects on each to get totals. " \
170
+ "Skip `_`-prefixed system classes other than `_User` and `_Role` (they may be empty, huge, or return errors). " \
171
+ "Group remaining classes by likely purpose (users/auth, content, app-specific) and summarize what the database is for."
172
+ },
173
+
174
+ "class_overview" => ->(args) {
175
+ cn = Validators.validate_identifier!(args["class_name"], "class_name")
176
+ "Describe the #{cn} class. Call get_schema for #{cn}, count_objects to get the total, and get_sample_objects (limit: 3). Summarize fields, what the class represents, and notable values in the samples."
177
+ },
178
+
179
+ "count_by" => ->(args) {
180
+ cn = Validators.validate_identifier!(args["class_name"], "class_name")
181
+ gb = Validators.validate_identifier!(args["group_by"], "group_by")
182
+ pipeline = [
183
+ { "$group" => { "_id" => "$#{gb}", "count" => { "$sum" => 1 } } },
184
+ { "$sort" => { "count" => -1 } },
185
+ { "$limit" => 25 },
186
+ ]
187
+ "Count #{cn} objects grouped by #{gb}. Use aggregate with class_name=\"#{cn}\" and pipeline #{pipeline.to_json}. " \
188
+ "If #{gb} is a pointer field, Parse returns each `_id` as the literal string \"ClassName$objectId\" (e.g. \"Team$abc123\") — strip the \"ClassName$\" prefix to recover the objectId, then optionally call get_object on a few to label them. " \
189
+ "Report the top groups, call out any null/missing values, and give the total."
190
+ },
191
+
192
+ "recent_activity" => ->(args) {
193
+ cn = Validators.validate_identifier!(args["class_name"], "class_name")
194
+ limit = (args["limit"] || 10).to_i
195
+ limit = 10 if limit <= 0
196
+ limit = 100 if limit > 100
197
+ "Show the #{limit} most recently created #{cn} objects. Use query_class with class_name=\"#{cn}\", order=\"-createdAt\", limit=#{limit}. Report the createdAt of the latest one prominently and highlight notable fields."
198
+ },
199
+
200
+ "find_relationship" => ->(args) {
201
+ pc = Validators.validate_identifier!(args["parent_class"], "parent_class")
202
+ pid = Validators.validate_object_id!(args["parent_id"], "parent_id")
203
+ cc = Validators.validate_identifier!(args["child_class"], "child_class")
204
+ pf = Validators.validate_identifier!(args["pointer_field"], "pointer_field")
205
+ where = { pf => { "__type" => "Pointer", "className" => pc, "objectId" => pid } }
206
+ "Find #{cc} objects whose #{pf} field points to #{pc} #{pid}. " \
207
+ "First call count_objects with class_name=\"#{cc}\" and where=#{where.to_json}. " \
208
+ "Then call query_class with the same constraint, limit 20, to show a sample. " \
209
+ "Note: #{pf} must match the field name as stored (camelCase as defined in the schema). Report the count first."
210
+ },
211
+
212
+ "created_in_range" => ->(args) {
213
+ cn = Validators.validate_identifier!(args["class_name"], "class_name")
214
+ since = Validators.validate_iso8601!(args["since"], "since")
215
+ upper = Validators.validate_iso8601!(args["until"], "until", required: false)
216
+ date_constraint = { "$gte" => { "__type" => "Date", "iso" => since } }
217
+ date_constraint["$lt"] = { "__type" => "Date", "iso" => upper } if upper
218
+ where = { "createdAt" => date_constraint }
219
+ "Count #{cn} objects created since #{since}#{upper ? " and before #{upper}" : ""}. " \
220
+ "Use count_objects with class_name=\"#{cn}\" and where=#{where.to_json}. " \
221
+ "Then call query_class with the same where, order=\"-createdAt\", limit=10 for a sample. Report the count and the date range of the sample."
222
+ },
223
+ }.freeze
224
+
225
+ # Thread-safety for the mutable registry.
226
+ REGISTRY_MUTEX = Mutex.new
227
+ private_constant :REGISTRY_MUTEX
228
+
229
+ # Mutable registry of custom prompts: name => { entry:, renderer: }
230
+ @registry = {}
231
+
232
+ # Subscribers notified when the registry changes (register or
233
+ # reset_registry!). Each entry is a callable invoked with no
234
+ # arguments. Used by Parse::Agent::MCPRackApp::SSEBody to push
235
+ # `notifications/prompts/list_changed` MCP events onto its SSE
236
+ # wire. Iterated under a snapshot copy outside the mutex so a
237
+ # misbehaving subscriber cannot block subsequent register calls.
238
+ @subscribers = []
239
+
240
+ class << self
241
+ # Returns the full list of prompt definitions for the MCP prompts/list
242
+ # response. Registered prompts override builtins with the same name.
243
+ #
244
+ # @return [Array<Hash>] array of prompt definition hashes with string keys.
245
+ def list
246
+ merged = {}
247
+ BUILTIN_PROMPTS.each { |p| merged[p["name"]] = p }
248
+ REGISTRY_MUTEX.synchronize do
249
+ @registry.each { |name, entry| merged[name] = entry[:prompt] }
250
+ end
251
+ merged.values
252
+ end
253
+
254
+ # Renders a prompt by name and returns the MCP prompts/get response shape.
255
+ #
256
+ # @param name [String] prompt name
257
+ # @param args [Hash<String,String>] user-supplied arguments
258
+ # @return [Hash] { "description" => String, "messages" => Array }
259
+ # @raise [Parse::Agent::ValidationError] if name is unknown or args fail validation
260
+ def render(name, args = {})
261
+ renderer = nil
262
+ REGISTRY_MUTEX.synchronize { renderer = @registry[name]&.fetch(:renderer, nil) }
263
+ renderer ||= BUILTIN_RENDERERS[name]
264
+
265
+ raise Parse::Agent::ValidationError, "Unknown prompt: #{name}" if renderer.nil?
266
+
267
+ result = renderer.call(args)
268
+
269
+ if result.is_a?(Hash)
270
+ description = (result[:description] || result["description"]).to_s
271
+ text = (result[:text] || result["text"]).to_s
272
+ else
273
+ description = "Parse analytics prompt: #{name}"
274
+ text = result.to_s
275
+ end
276
+
277
+ {
278
+ "description" => description,
279
+ "messages" => [
280
+ {
281
+ "role" => "user",
282
+ "content" => { "type" => "text", "text" => text },
283
+ },
284
+ ],
285
+ }
286
+ end
287
+
288
+ # Register a custom prompt. Thread-safe. Idempotent on same name (replaces).
289
+ #
290
+ # @param name [String] unique prompt name
291
+ # @param description [String] human-readable description
292
+ # @param arguments [Array<Hash>] argument definitions with string keys
293
+ # @param renderer [Proc] lambda accepting an args Hash; returns String or
294
+ # Hash with :description and :text keys
295
+ def register(name:, description:, arguments: [], renderer:)
296
+ prompt = {
297
+ "name" => name.to_s,
298
+ "description" => description.to_s,
299
+ "arguments" => arguments,
300
+ }
301
+ REGISTRY_MUTEX.synchronize do
302
+ @registry[name.to_s] = { prompt: prompt, renderer: renderer }
303
+ end
304
+ notify_subscribers
305
+ nil
306
+ end
307
+
308
+ # Clears the custom registry, restoring builtins-only state.
309
+ # Intended for use in test suites.
310
+ def reset_registry!
311
+ REGISTRY_MUTEX.synchronize { @registry.clear }
312
+ notify_subscribers
313
+ nil
314
+ end
315
+
316
+ # Subscribe to registry-changed events. The block is invoked
317
+ # with no arguments after every {register} or {reset_registry!}
318
+ # call. Returns a Proc that, when called, deregisters the
319
+ # subscriber. Used by Parse::Agent::MCPRackApp::SSEBody to drive
320
+ # MCP `notifications/prompts/list_changed` broadcasts.
321
+ #
322
+ # @yield no arguments
323
+ # @return [Proc] call with no arguments to deregister.
324
+ def subscribe(&block)
325
+ raise ArgumentError, "block required" unless block
326
+
327
+ REGISTRY_MUTEX.synchronize { @subscribers << block }
328
+ -> { REGISTRY_MUTEX.synchronize { @subscribers.delete(block) } }
329
+ end
330
+
331
+ # Remove all subscribers. Intended for test suites.
332
+ def reset_subscribers!
333
+ REGISTRY_MUTEX.synchronize { @subscribers.clear }
334
+ nil
335
+ end
336
+
337
+ # @api private
338
+ def notify_subscribers
339
+ snapshot = REGISTRY_MUTEX.synchronize { @subscribers.dup }
340
+ snapshot.each do |callback|
341
+ begin
342
+ callback.call
343
+ rescue StandardError => e
344
+ warn "[Parse::Agent::Prompts] subscriber raised: #{e.class}: #{e.message}"
345
+ end
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,158 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "thread"
5
+
6
+ module Parse
7
+ class Agent
8
+ # Thread-safe rate limiter using a sliding window algorithm.
9
+ #
10
+ # Prevents resource exhaustion by limiting the number of requests
11
+ # an agent can make within a time window.
12
+ #
13
+ # @example Basic usage
14
+ # limiter = RateLimiter.new(limit: 60, window: 60) # 60 requests per minute
15
+ #
16
+ # limiter.check! # Passes
17
+ # # ... after too many requests ...
18
+ # limiter.check! # raises RateLimitExceeded
19
+ #
20
+ # @example Check without raising
21
+ # if limiter.available?
22
+ # # Make request
23
+ # else
24
+ # puts "Rate limited, retry after #{limiter.retry_after}s"
25
+ # end
26
+ #
27
+ class RateLimiter
28
+ # Error raised when rate limit is exceeded
29
+ class RateLimitExceeded < StandardError
30
+ attr_reader :retry_after, :limit, :window
31
+
32
+ def initialize(retry_after:, limit:, window:)
33
+ @retry_after = retry_after
34
+ @limit = limit
35
+ @window = window
36
+ super("Rate limit exceeded (#{limit} requests per #{window}s). Retry after #{retry_after.round(1)}s")
37
+ end
38
+ end
39
+
40
+ # Default requests allowed per window
41
+ DEFAULT_LIMIT = 60
42
+
43
+ # Default time window in seconds
44
+ DEFAULT_WINDOW = 60
45
+
46
+ # @return [Integer] maximum requests allowed per window
47
+ attr_reader :limit
48
+
49
+ # @return [Integer] time window in seconds
50
+ attr_reader :window
51
+
52
+ # Create a new rate limiter.
53
+ #
54
+ # @param limit [Integer] maximum requests per window (default: 60)
55
+ # @param window [Integer] time window in seconds (default: 60)
56
+ def initialize(limit: DEFAULT_LIMIT, window: DEFAULT_WINDOW)
57
+ @limit = limit
58
+ @window = window
59
+ @requests = []
60
+ @mutex = Mutex.new
61
+ end
62
+
63
+ # Check rate limit and record request. Raises if limit exceeded.
64
+ #
65
+ # @raise [RateLimitExceeded] if rate limit is exceeded
66
+ # @return [true] if request is allowed
67
+ def check!
68
+ @mutex.synchronize do
69
+ cleanup_old_requests
70
+
71
+ if @requests.size >= @limit
72
+ retry_after = calculate_retry_after
73
+ raise RateLimitExceeded.new(
74
+ retry_after: retry_after,
75
+ limit: @limit,
76
+ window: @window,
77
+ )
78
+ end
79
+
80
+ @requests << Time.now.to_f
81
+ true
82
+ end
83
+ end
84
+
85
+ # Check if a request can be made without blocking.
86
+ #
87
+ # @return [Boolean] true if request would be allowed
88
+ def available?
89
+ @mutex.synchronize do
90
+ cleanup_old_requests
91
+ @requests.size < @limit
92
+ end
93
+ end
94
+
95
+ # Get the number of remaining requests in current window.
96
+ #
97
+ # @return [Integer] remaining requests
98
+ def remaining
99
+ @mutex.synchronize do
100
+ cleanup_old_requests
101
+ [@limit - @requests.size, 0].max
102
+ end
103
+ end
104
+
105
+ # Get seconds until rate limit resets (oldest request expires).
106
+ #
107
+ # @return [Float, nil] seconds until reset, or nil if not limited
108
+ def retry_after
109
+ @mutex.synchronize do
110
+ cleanup_old_requests
111
+ return nil if @requests.size < @limit
112
+ calculate_retry_after
113
+ end
114
+ end
115
+
116
+ # Reset the rate limiter (clear all recorded requests).
117
+ #
118
+ # @return [void]
119
+ def reset!
120
+ @mutex.synchronize do
121
+ @requests.clear
122
+ end
123
+ end
124
+
125
+ # Get rate limiter statistics.
126
+ #
127
+ # @return [Hash] current state information
128
+ def stats
129
+ @mutex.synchronize do
130
+ cleanup_old_requests
131
+ {
132
+ limit: @limit,
133
+ window: @window,
134
+ used: @requests.size,
135
+ remaining: [@limit - @requests.size, 0].max,
136
+ retry_after: @requests.size >= @limit ? calculate_retry_after : nil,
137
+ }
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ # Remove requests older than the time window
144
+ def cleanup_old_requests
145
+ cutoff = Time.now.to_f - @window
146
+ @requests.reject! { |t| t < cutoff }
147
+ end
148
+
149
+ # Calculate seconds until oldest request expires
150
+ def calculate_retry_after
151
+ return 0.1 if @requests.empty?
152
+ oldest = @requests.first
153
+ time_until_expire = oldest + @window - Time.now.to_f
154
+ [time_until_expire, 0.1].max
155
+ end
156
+ end
157
+ end
158
+ end