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.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- 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
|