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,376 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "webrick"
|
|
5
|
+
require "json"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "active_support/core_ext/object/blank"
|
|
8
|
+
require "active_support/security_utils"
|
|
9
|
+
|
|
10
|
+
require_relative "prompts"
|
|
11
|
+
require_relative "mcp_dispatcher"
|
|
12
|
+
require_relative "mcp_rack_app"
|
|
13
|
+
|
|
14
|
+
module Parse
|
|
15
|
+
class Agent
|
|
16
|
+
# MCP (Model Context Protocol) HTTP Server for Parse Stack.
|
|
17
|
+
# Enables external AI agents (Claude, LM Studio, etc.) to interact with
|
|
18
|
+
# Parse data over HTTP using the MCP protocol specification.
|
|
19
|
+
#
|
|
20
|
+
# Since the Rack refactor this class is a thin WEBrick wrapper around
|
|
21
|
+
# {Parse::Agent::MCPRackApp}. Embedded deployments (Sinatra, Rails) should
|
|
22
|
+
# mount MCPRackApp directly with their own agent factory; this class
|
|
23
|
+
# remains for standalone server deployments and back-compat.
|
|
24
|
+
#
|
|
25
|
+
# @example Start the server
|
|
26
|
+
# Parse::Agent.enable_mcp!
|
|
27
|
+
# Parse::Agent::MCPServer.run(port: 3001)
|
|
28
|
+
#
|
|
29
|
+
# @example With custom configuration
|
|
30
|
+
# server = Parse::Agent::MCPServer.new(
|
|
31
|
+
# port: 3001,
|
|
32
|
+
# permissions: :readonly,
|
|
33
|
+
# session_token: nil
|
|
34
|
+
# )
|
|
35
|
+
# server.start
|
|
36
|
+
#
|
|
37
|
+
# @see https://modelcontextprotocol.io/ MCP Protocol Specification
|
|
38
|
+
# @see Parse::Agent::MCPRackApp for embedded mounting
|
|
39
|
+
#
|
|
40
|
+
class MCPServer
|
|
41
|
+
# MCP Protocol version
|
|
42
|
+
PROTOCOL_VERSION = MCPDispatcher::PROTOCOL_VERSION
|
|
43
|
+
|
|
44
|
+
# Server capabilities
|
|
45
|
+
CAPABILITIES = MCPDispatcher::CAPABILITIES
|
|
46
|
+
|
|
47
|
+
# Default port for the MCP server
|
|
48
|
+
@default_port = 3001
|
|
49
|
+
|
|
50
|
+
# Maximum allowed request body size (1 MB) — kept as a back-compat constant.
|
|
51
|
+
MAX_BODY_SIZE = MCPRackApp::DEFAULT_MAX_BODY_SIZE
|
|
52
|
+
|
|
53
|
+
# Maximum JSON nesting depth — kept as a back-compat constant.
|
|
54
|
+
MAX_JSON_NESTING = MCPRackApp::MAX_JSON_NESTING
|
|
55
|
+
|
|
56
|
+
# HTTP header for MCP API key authentication
|
|
57
|
+
MCP_API_KEY_HEADER = "X-MCP-API-Key"
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
attr_accessor :default_port
|
|
61
|
+
|
|
62
|
+
# Start the MCP server (blocking)
|
|
63
|
+
#
|
|
64
|
+
# @param port [Integer] port to listen on
|
|
65
|
+
# @param permissions [Symbol] agent permission level
|
|
66
|
+
# @param session_token [String, nil] optional session token
|
|
67
|
+
# @param host [String] host to bind to
|
|
68
|
+
# @param rate_limiter [#check!, nil] optional external rate limiter
|
|
69
|
+
def run(port: nil, permissions: :readonly, session_token: nil, host: "127.0.0.1", api_key: nil, rate_limiter: nil)
|
|
70
|
+
unless Parse::Agent.mcp_enabled?
|
|
71
|
+
raise "MCP server not enabled. Call Parse::Agent.enable_mcp! first"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
server = new(
|
|
75
|
+
port: port || @default_port,
|
|
76
|
+
permissions: permissions,
|
|
77
|
+
session_token: session_token,
|
|
78
|
+
host: host,
|
|
79
|
+
api_key: api_key,
|
|
80
|
+
rate_limiter: rate_limiter,
|
|
81
|
+
)
|
|
82
|
+
server.start
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Integer] the port number
|
|
87
|
+
attr_reader :port
|
|
88
|
+
|
|
89
|
+
# @return [String] the host to bind to
|
|
90
|
+
attr_reader :host
|
|
91
|
+
|
|
92
|
+
# @return [Parse::Agent] the template agent used by the /tools listing
|
|
93
|
+
# endpoint and as a settings source for per-request agents. Hot tools
|
|
94
|
+
# in MCP requests run against fresh per-request instances; do NOT
|
|
95
|
+
# share this object across threads for mutable state inspection.
|
|
96
|
+
attr_reader :agent
|
|
97
|
+
|
|
98
|
+
# Create a new MCP server instance
|
|
99
|
+
#
|
|
100
|
+
# @param port [Integer] port to listen on
|
|
101
|
+
# @param host [String] host to bind to
|
|
102
|
+
# @param permissions [Symbol] agent permission level
|
|
103
|
+
# @param session_token [String, nil] optional session token
|
|
104
|
+
# @param rate_limiter [#check!, nil] optional external rate limiter (e.g.
|
|
105
|
+
# Redis-backed). When provided, replaces the default in-process
|
|
106
|
+
# {Parse::Agent::RateLimiter}. Must respond to `#check!` and raise
|
|
107
|
+
# {Parse::Agent::RateLimitExceeded} when the budget is exhausted.
|
|
108
|
+
# @raise [ArgumentError] if rate_limiter is provided but does not respond to :check!
|
|
109
|
+
# Loopback hosts that are safe to bind to without an API key.
|
|
110
|
+
LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
|
|
111
|
+
|
|
112
|
+
def initialize(port: 3001, host: "127.0.0.1", permissions: :readonly,
|
|
113
|
+
session_token: nil, api_key: nil, rate_limiter: nil,
|
|
114
|
+
pre_auth_rate_limiter: nil,
|
|
115
|
+
allowed_origins: nil, require_custom_header: nil)
|
|
116
|
+
if rate_limiter && !rate_limiter.respond_to?(:check!)
|
|
117
|
+
raise ArgumentError, "rate_limiter must respond to #check!"
|
|
118
|
+
end
|
|
119
|
+
if pre_auth_rate_limiter && !pre_auth_rate_limiter.respond_to?(:check!)
|
|
120
|
+
raise ArgumentError, "pre_auth_rate_limiter must respond to #check!"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
effective_api_key = api_key || ENV["MCP_API_KEY"]
|
|
124
|
+
|
|
125
|
+
# NEW-MCP-1: a non-loopback bind without an API key is an unauthenticated
|
|
126
|
+
# network-exposed JSON-RPC endpoint. Refuse to start. Operators who
|
|
127
|
+
# genuinely want this — e.g., behind a reverse proxy that handles
|
|
128
|
+
# auth — should bind to localhost and let the proxy forward, or
|
|
129
|
+
# set MCP_API_KEY explicitly even when "the proxy authenticates"
|
|
130
|
+
# (defense in depth).
|
|
131
|
+
if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.to_s.empty?
|
|
132
|
+
raise ArgumentError,
|
|
133
|
+
"MCPServer refuses to bind non-loopback host #{host.inspect} without an api_key. " \
|
|
134
|
+
"Set MCP_API_KEY in the environment, pass api_key: explicitly, or use a loopback " \
|
|
135
|
+
"host (one of: #{LOOPBACK_HOSTS.join(', ')})."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@port = port
|
|
139
|
+
@host = host
|
|
140
|
+
@api_key = effective_api_key
|
|
141
|
+
@permissions = permissions
|
|
142
|
+
@session_token = session_token
|
|
143
|
+
|
|
144
|
+
# Shared limiter across requests so per-request agents (built in
|
|
145
|
+
# agent_factory) don't reset their window on every call. The
|
|
146
|
+
# rate-limit budget is a server-level resource, not a per-Agent one.
|
|
147
|
+
@shared_rate_limiter = rate_limiter || RateLimiter.new
|
|
148
|
+
|
|
149
|
+
# Template agent for the /tools listing endpoint and for inspection
|
|
150
|
+
# via #agent. NOT used for live request dispatch — see agent_factory.
|
|
151
|
+
@agent = Parse::Agent.new(
|
|
152
|
+
permissions: @permissions,
|
|
153
|
+
session_token: @session_token,
|
|
154
|
+
rate_limiter: @shared_rate_limiter,
|
|
155
|
+
)
|
|
156
|
+
@server = nil
|
|
157
|
+
|
|
158
|
+
# The Rack app does the heavy lifting. Its agent_factory enforces the
|
|
159
|
+
# API key and constructs a FRESH Parse::Agent per request so the
|
|
160
|
+
# per-instance state (@conversation_history, @operation_log, token
|
|
161
|
+
# counters) cannot leak between requests.
|
|
162
|
+
# pre_auth_rate_limiter: closes NEW-MCP-6 — runs before the factory
|
|
163
|
+
# is invoked so an empty or malformed body can't amplify into a
|
|
164
|
+
# Parse Server round-trip.
|
|
165
|
+
@rack_app = MCPRackApp.new(
|
|
166
|
+
agent_factory: method(:agent_factory),
|
|
167
|
+
pre_auth_rate_limiter: pre_auth_rate_limiter,
|
|
168
|
+
allowed_origins: allowed_origins,
|
|
169
|
+
require_custom_header: require_custom_header,
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Start the HTTP server (blocking)
|
|
174
|
+
def start
|
|
175
|
+
@server = WEBrick::HTTPServer.new(
|
|
176
|
+
Port: @port,
|
|
177
|
+
BindAddress: @host,
|
|
178
|
+
Logger: WEBrick::Log.new($stdout, WEBrick::Log::INFO),
|
|
179
|
+
AccessLog: [[::File.open(::File::NULL, "w"), ""]], # Suppress access log
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
setup_routes
|
|
183
|
+
|
|
184
|
+
trap("INT") { stop }
|
|
185
|
+
trap("TERM") { stop }
|
|
186
|
+
|
|
187
|
+
puts "Parse MCP Server starting on http://#{@host}:#{@port}"
|
|
188
|
+
puts "Permissions: #{@agent.permissions}"
|
|
189
|
+
puts "Tools available: #{@agent.allowed_tools.join(", ")}"
|
|
190
|
+
|
|
191
|
+
@server.start
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Stop the server
|
|
195
|
+
def stop
|
|
196
|
+
@server&.shutdown
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def setup_routes
|
|
202
|
+
# MCP endpoint — translated WEBrick request → Rack env → MCPRackApp.
|
|
203
|
+
@server.mount_proc("/mcp") { |req, res| handle_mcp_request(req, res) }
|
|
204
|
+
|
|
205
|
+
# Health check endpoint (unauthenticated - standard for monitoring)
|
|
206
|
+
@server.mount_proc("/health") do |_req, res|
|
|
207
|
+
json_response(res, { status: "ok", mcp_enabled: true })
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Tool list endpoint (requires auth if API key is configured)
|
|
211
|
+
@server.mount_proc("/tools") do |req, res|
|
|
212
|
+
if @api_key.present?
|
|
213
|
+
provided_key = req[MCP_API_KEY_HEADER].to_s
|
|
214
|
+
unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
|
|
215
|
+
error_response(res, 401, "Unauthorized: invalid or missing API key")
|
|
216
|
+
next
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
json_response(res, @agent.tool_definitions(format: :mcp))
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Translate a WEBrick request into a minimal Rack env and dispatch to the
|
|
224
|
+
# MCPRackApp. The agent_factory bound at construction handles API-key
|
|
225
|
+
# auth and returns the shared @agent for valid requests.
|
|
226
|
+
#
|
|
227
|
+
# WEBrick HTTPRequest#body reads lazily from the socket. We must reject
|
|
228
|
+
# oversized bodies BEFORE calling req.body. Two attack shapes:
|
|
229
|
+
# (a) Content-Length > MAX_BODY_SIZE — caught by the explicit check.
|
|
230
|
+
# (b) Transfer-Encoding: chunked with no Content-Length — WEBrick's
|
|
231
|
+
# read_chunked has no size cap and will dechunk indefinitely.
|
|
232
|
+
# We refuse (b) entirely: chunked or missing-Content-Length requests
|
|
233
|
+
# return 411 "Length Required" before req.body is ever called.
|
|
234
|
+
def handle_mcp_request(req, res)
|
|
235
|
+
# NEW-MCP-5: WEBrick's mount_proc("/mcp") is prefix-matching, so
|
|
236
|
+
# `/mcp/anything/at/all` reaches this handler and forwards the
|
|
237
|
+
# extra path segments into the Rack app via PATH_INFO. Reverse
|
|
238
|
+
# proxies that enforce ACLs against `^/mcp$` (or that route
|
|
239
|
+
# `/mcp/admin` to a different upstream) will be defeated by the
|
|
240
|
+
# prefix match unless we explicitly reject sub-paths here. A
|
|
241
|
+
# trailing slash is accepted — `/mcp/` is the same endpoint —
|
|
242
|
+
# but anything beyond is 404.
|
|
243
|
+
normalized = req.path.to_s.chomp("/")
|
|
244
|
+
unless normalized == "/mcp"
|
|
245
|
+
res.status = 404
|
|
246
|
+
res.content_type = "application/json"
|
|
247
|
+
res.body = JSON.generate({
|
|
248
|
+
"jsonrpc" => "2.0",
|
|
249
|
+
"id" => nil,
|
|
250
|
+
"error" => { "code" => -32_601, "message" => "Not Found" },
|
|
251
|
+
})
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Method gate FIRST. Returning 411 for a GET because it lacks a
|
|
256
|
+
# Content-Length is semantically wrong (the method itself is not
|
|
257
|
+
# allowed; body requirements never apply) and surprises Rack
|
|
258
|
+
# middleware that expects 405 for method-mismatch.
|
|
259
|
+
unless req.request_method == "POST"
|
|
260
|
+
res.status = 405
|
|
261
|
+
res["Allow"] = "POST"
|
|
262
|
+
res.content_type = "application/json"
|
|
263
|
+
res.body = JSON.generate({
|
|
264
|
+
"jsonrpc" => "2.0",
|
|
265
|
+
"id" => nil,
|
|
266
|
+
"error" => { "code" => -32_600, "message" => "Method Not Allowed: only POST is accepted" },
|
|
267
|
+
})
|
|
268
|
+
return
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
transfer_encoding = req["Transfer-Encoding"].to_s.downcase
|
|
272
|
+
content_length_header = req["Content-Length"]
|
|
273
|
+
if transfer_encoding.include?("chunked") || content_length_header.nil?
|
|
274
|
+
res.status = 411
|
|
275
|
+
res.content_type = "application/json"
|
|
276
|
+
res.body = JSON.generate({
|
|
277
|
+
"jsonrpc" => "2.0",
|
|
278
|
+
"id" => nil,
|
|
279
|
+
"error" => { "code" => -32_700, "message" => "Length Required: Content-Length header is required and Transfer-Encoding: chunked is not accepted" },
|
|
280
|
+
})
|
|
281
|
+
return
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
content_length = content_length_header.to_i
|
|
285
|
+
if content_length > MCPRackApp::DEFAULT_MAX_BODY_SIZE
|
|
286
|
+
res.status = 413
|
|
287
|
+
res.content_type = "application/json"
|
|
288
|
+
res.body = JSON.generate({
|
|
289
|
+
"jsonrpc" => "2.0",
|
|
290
|
+
"id" => nil,
|
|
291
|
+
"error" => { "code" => -32_700, "message" => "Payload Too Large: body exceeds #{MCPRackApp::DEFAULT_MAX_BODY_SIZE} bytes" },
|
|
292
|
+
})
|
|
293
|
+
return
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
env = build_rack_env(req)
|
|
297
|
+
status, headers, body_chunks = @rack_app.call(env)
|
|
298
|
+
|
|
299
|
+
res.status = status
|
|
300
|
+
rack_ct = headers["Content-Type"] || headers["content-type"]
|
|
301
|
+
headers.each { |k, v| res[k] = v unless k.casecmp("Content-Type").zero? }
|
|
302
|
+
res.content_type = rack_ct if rack_ct
|
|
303
|
+
res.body = body_chunks.join
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Agent factory passed to MCPRackApp. Enforces the API-key check (raising
|
|
307
|
+
# Parse::Agent::Unauthorized so the Rack app renders a sanitized 401)
|
|
308
|
+
# and then constructs a FRESH Parse::Agent per request, sharing only
|
|
309
|
+
# the @shared_rate_limiter so the budget persists across calls.
|
|
310
|
+
#
|
|
311
|
+
# The per-instance @conversation_history, @operation_log, and token
|
|
312
|
+
# counters on each returned agent are scoped to that single request
|
|
313
|
+
# and discarded when it ends, eliminating cross-request leakage.
|
|
314
|
+
def agent_factory(env)
|
|
315
|
+
if @api_key.present?
|
|
316
|
+
provided_key = env["HTTP_X_MCP_API_KEY"].to_s
|
|
317
|
+
unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
|
|
318
|
+
raise Parse::Agent::Unauthorized.new("invalid or missing API key", reason: :bad_api_key)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
Parse::Agent.new(
|
|
323
|
+
permissions: @permissions,
|
|
324
|
+
session_token: @session_token,
|
|
325
|
+
rate_limiter: @shared_rate_limiter,
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Build a minimal Rack env from a WEBrick request. We populate the
|
|
330
|
+
# fields MCPRackApp reads (REQUEST_METHOD, CONTENT_TYPE, rack.input,
|
|
331
|
+
# HTTP_X_MCP_API_KEY) plus a few Rack-required keys so middleware that
|
|
332
|
+
# might wrap us still sees a plausible env. Per the Rack SPEC, the
|
|
333
|
+
# special Content-Type and Content-Length headers are top-level keys
|
|
334
|
+
# (no HTTP_ prefix), so the header-enumeration loop excludes them.
|
|
335
|
+
RACK_TOP_LEVEL_HEADERS = %w[Content-Type Content-Length].freeze
|
|
336
|
+
|
|
337
|
+
def build_rack_env(req)
|
|
338
|
+
env = {
|
|
339
|
+
"REQUEST_METHOD" => req.request_method,
|
|
340
|
+
"CONTENT_TYPE" => req["Content-Type"].to_s,
|
|
341
|
+
"CONTENT_LENGTH" => req["Content-Length"].to_s,
|
|
342
|
+
"rack.input" => StringIO.new(req.body || ""),
|
|
343
|
+
"rack.errors" => $stderr,
|
|
344
|
+
"rack.url_scheme" => "http",
|
|
345
|
+
"SERVER_NAME" => @host,
|
|
346
|
+
"SERVER_PORT" => @port.to_s,
|
|
347
|
+
"PATH_INFO" => req.path,
|
|
348
|
+
"QUERY_STRING" => req.query_string.to_s,
|
|
349
|
+
}
|
|
350
|
+
req.each do |name|
|
|
351
|
+
next if RACK_TOP_LEVEL_HEADERS.any? { |h| name.casecmp(h).zero? }
|
|
352
|
+
# NEW-MCP-2: refuse header names that already contain underscores.
|
|
353
|
+
# `X-MCP-API-Key` and `X_MCP_API_KEY` both collapse to the same
|
|
354
|
+
# Rack env key (`HTTP_X_MCP_API_KEY`); a reverse proxy that
|
|
355
|
+
# injects the trusted dash-form can be undermined by an attacker
|
|
356
|
+
# also sending the underscore-form. Drop the underscore variant
|
|
357
|
+
# at the transport layer.
|
|
358
|
+
next if name.include?("_")
|
|
359
|
+
header_key = "HTTP_#{name.upcase.tr("-", "_")}"
|
|
360
|
+
env[header_key] = req[name].to_s
|
|
361
|
+
end
|
|
362
|
+
env
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def json_response(res, data)
|
|
366
|
+
res.content_type = "application/json"
|
|
367
|
+
res.body = JSON.generate(data)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def error_response(res, status, message)
|
|
371
|
+
res.status = status
|
|
372
|
+
json_response(res, { error: message })
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
class Agent
|
|
6
|
+
# Boot-time / on-demand audit of agent metadata declarations across
|
|
7
|
+
# the application's Parse::Object subclasses. Surfaces the gaps that
|
|
8
|
+
# silently degrade an LLM's experience of the schema: classes with no
|
|
9
|
+
# `agent_description`, properties on the allowlist with no
|
|
10
|
+
# `_description:`, and `agent_fields` entries that don't resolve to
|
|
11
|
+
# known wire columns.
|
|
12
|
+
#
|
|
13
|
+
# Returns structured data so callers can wire it into a boot warning,
|
|
14
|
+
# a CI gate, or a Rake task. `print_summary` is a convenience for
|
|
15
|
+
# interactive use (rails console, scripts).
|
|
16
|
+
#
|
|
17
|
+
# @example Programmatic use
|
|
18
|
+
# audit = Parse::Agent.audit_metadata
|
|
19
|
+
# if audit[:missing_class_descriptions].any?
|
|
20
|
+
# warn "Classes without descriptions: #{audit[:missing_class_descriptions]}"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example Interactive use
|
|
24
|
+
# Parse::Agent::MetadataAudit.print_summary
|
|
25
|
+
module MetadataAudit
|
|
26
|
+
extend self
|
|
27
|
+
|
|
28
|
+
# System/system-adjacent fields that are always present on every
|
|
29
|
+
# Parse class and don't benefit from `_description:`. Excluded from
|
|
30
|
+
# the missing-field-descriptions report.
|
|
31
|
+
ALWAYS_PRESENT_FIELDS = %i[
|
|
32
|
+
object_id objectId
|
|
33
|
+
created_at createdAt
|
|
34
|
+
updated_at updatedAt
|
|
35
|
+
acl ACL
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Run the audit and return structured findings.
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash]
|
|
41
|
+
# * :classes_audited [Integer] — number of classes inspected
|
|
42
|
+
# * :visible_classes_declared [Boolean] — whether the app uses
|
|
43
|
+
# opt-in `agent_visible` mode
|
|
44
|
+
# * :missing_class_descriptions [Array<String>] — Parse class names
|
|
45
|
+
# with no `agent_description`
|
|
46
|
+
# * :missing_field_descriptions [Hash<String, Array<Symbol>>] —
|
|
47
|
+
# class name -> property symbols missing `_description:`. When
|
|
48
|
+
# a class declares `agent_fields`, only allowlisted properties
|
|
49
|
+
# are counted; otherwise all declared properties.
|
|
50
|
+
# * :unresolvable_allowlist_entries [Hash<String, Array<Symbol>>] —
|
|
51
|
+
# `agent_fields` entries that don't appear in the class's
|
|
52
|
+
# `field_map` (likely typos that 4.2.1's wire-name translation
|
|
53
|
+
# will silently miss).
|
|
54
|
+
# * :canonical_filter_summary [Hash<String, Hash>] — per-class
|
|
55
|
+
# declared canonical filters, surfaced so the auditor can see
|
|
56
|
+
# which classes apply silent row-level predicates by default.
|
|
57
|
+
def audit
|
|
58
|
+
classes = audit_target_classes
|
|
59
|
+
|
|
60
|
+
result = {
|
|
61
|
+
classes_audited: classes.size,
|
|
62
|
+
visible_classes_declared: Parse::Agent::MetadataRegistry.has_visible_classes?,
|
|
63
|
+
missing_class_descriptions: [],
|
|
64
|
+
missing_field_descriptions: {},
|
|
65
|
+
unresolvable_allowlist_entries: {},
|
|
66
|
+
canonical_filter_summary: {},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
classes.each do |klass|
|
|
70
|
+
name = parse_class_name_for(klass)
|
|
71
|
+
next if name.nil?
|
|
72
|
+
|
|
73
|
+
# Skip classes flagged agent_hidden — they're intentionally
|
|
74
|
+
# opaque to the agent surface, and we shouldn't pretend the
|
|
75
|
+
# missing description on them is a gap.
|
|
76
|
+
next if klass.respond_to?(:agent_hidden?) && klass.agent_hidden?
|
|
77
|
+
|
|
78
|
+
# Skip Parse system classes (`_`-prefixed parse_class names:
|
|
79
|
+
# `_User`, `_Role`, `_Session`, `_Installation`, `_Product`,
|
|
80
|
+
# `_Audience`). These are framework-supplied by parse-stack and
|
|
81
|
+
# don't benefit from userland-authored agent_description — the
|
|
82
|
+
# SDK is responsible for documenting them, not the application.
|
|
83
|
+
# Without this skip, every app that doesn't opt into
|
|
84
|
+
# `agent_visible` mode sees the system classes flooding
|
|
85
|
+
# `missing_class_descriptions`, which discourages adoption of
|
|
86
|
+
# the audit tool. Apps that DO want to document their system
|
|
87
|
+
# classes can still call `agent_description` on `Parse::User`
|
|
88
|
+
# etc. — the skip only suppresses the "missing" reports, not
|
|
89
|
+
# the legitimate ones.
|
|
90
|
+
next if name.to_s.start_with?("_")
|
|
91
|
+
|
|
92
|
+
if klass.respond_to?(:agent_description) && klass.agent_description.nil?
|
|
93
|
+
result[:missing_class_descriptions] << name
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
missing_fields = missing_field_descriptions_for(klass)
|
|
97
|
+
result[:missing_field_descriptions][name] = missing_fields if missing_fields.any?
|
|
98
|
+
|
|
99
|
+
unresolvable = unresolvable_allowlist_entries_for(klass)
|
|
100
|
+
result[:unresolvable_allowlist_entries][name] = unresolvable if unresolvable.any?
|
|
101
|
+
|
|
102
|
+
if klass.respond_to?(:agent_canonical_filter_for_apply) &&
|
|
103
|
+
(cf = klass.agent_canonical_filter_for_apply) &&
|
|
104
|
+
cf.any?
|
|
105
|
+
result[:canonical_filter_summary][name] = cf.dup
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
result[:missing_class_descriptions].sort!
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Print a human-readable summary to the given IO (defaults to $stdout).
|
|
114
|
+
# The structured data from {#audit} is the source of truth; this is a
|
|
115
|
+
# convenience for interactive sessions.
|
|
116
|
+
#
|
|
117
|
+
# @param io [IO] destination (default $stdout)
|
|
118
|
+
# @return [Hash] the audit findings (same shape as {#audit})
|
|
119
|
+
def print_summary(io: $stdout)
|
|
120
|
+
data = audit
|
|
121
|
+
|
|
122
|
+
io.puts "Parse::Agent metadata audit"
|
|
123
|
+
io.puts "=" * 40
|
|
124
|
+
io.puts "Classes audited: #{data[:classes_audited]} " \
|
|
125
|
+
"(#{data[:visible_classes_declared] ? "agent_visible mode" : "all-subclasses fallback"})"
|
|
126
|
+
io.puts
|
|
127
|
+
|
|
128
|
+
missing_classes = data[:missing_class_descriptions]
|
|
129
|
+
io.puts "Missing class descriptions (#{missing_classes.size}):"
|
|
130
|
+
if missing_classes.empty?
|
|
131
|
+
io.puts " (none)"
|
|
132
|
+
else
|
|
133
|
+
missing_classes.each { |n| io.puts " - #{n}" }
|
|
134
|
+
end
|
|
135
|
+
io.puts
|
|
136
|
+
|
|
137
|
+
missing_fields = data[:missing_field_descriptions]
|
|
138
|
+
total_missing_fields = missing_fields.values.sum(&:size)
|
|
139
|
+
io.puts "Missing field descriptions (#{total_missing_fields} across #{missing_fields.size} classes):"
|
|
140
|
+
if missing_fields.empty?
|
|
141
|
+
io.puts " (none)"
|
|
142
|
+
else
|
|
143
|
+
missing_fields.sort.each do |class_name, fields|
|
|
144
|
+
io.puts " #{class_name} (#{fields.size}):"
|
|
145
|
+
io.puts " #{fields.map(&:to_s).join(", ")}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
io.puts
|
|
149
|
+
|
|
150
|
+
unresolvable = data[:unresolvable_allowlist_entries]
|
|
151
|
+
io.puts "Unresolvable allowlist entries:"
|
|
152
|
+
if unresolvable.empty?
|
|
153
|
+
io.puts " (none)"
|
|
154
|
+
else
|
|
155
|
+
unresolvable.sort.each do |class_name, entries|
|
|
156
|
+
io.puts " #{class_name}: #{entries.map(&:to_s).join(", ")}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
io.puts
|
|
160
|
+
|
|
161
|
+
filters = data[:canonical_filter_summary]
|
|
162
|
+
io.puts "Canonical filters declared (#{filters.size}):"
|
|
163
|
+
if filters.empty?
|
|
164
|
+
io.puts " (none)"
|
|
165
|
+
else
|
|
166
|
+
filters.sort.each do |class_name, filter|
|
|
167
|
+
io.puts " #{class_name}: #{filter.inspect}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
data
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ----------------------------------------------------------------
|
|
175
|
+
# Internals
|
|
176
|
+
# ----------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
# Resolve the set of classes to audit.
|
|
179
|
+
#
|
|
180
|
+
# When the application has opted into `agent_visible` mode, that
|
|
181
|
+
# registry IS the canonical list — the developer has explicitly said
|
|
182
|
+
# "these are the agent-facing classes." Otherwise fall back to every
|
|
183
|
+
# Parse::Object subclass currently loaded (back-compat mode).
|
|
184
|
+
#
|
|
185
|
+
# @return [Array<Class>]
|
|
186
|
+
def audit_target_classes
|
|
187
|
+
if Parse::Agent::MetadataRegistry.has_visible_classes?
|
|
188
|
+
Parse::Agent::MetadataRegistry.visible_classes
|
|
189
|
+
else
|
|
190
|
+
# `Parse::Object.descendants` is the same iteration path used by
|
|
191
|
+
# `Parse::Model.find_class` to resolve a Parse class name to a
|
|
192
|
+
# Ruby class. Walks every loaded subclass without going through
|
|
193
|
+
# the find_class cache (which raises NameError on miss and would
|
|
194
|
+
# corrupt the audit's "what's declared" view).
|
|
195
|
+
Parse::Object.descendants.select do |klass|
|
|
196
|
+
klass.respond_to?(:parse_class) && klass.parse_class
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The Parse-side class name for a Ruby class, or nil when the class
|
|
202
|
+
# isn't a normal Parse::Object subclass (defensive — every entry in
|
|
203
|
+
# audit_target_classes should pass this).
|
|
204
|
+
def parse_class_name_for(klass)
|
|
205
|
+
return nil unless klass.respond_to?(:parse_class)
|
|
206
|
+
klass.parse_class
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Build the list of property symbols on a class that have no
|
|
210
|
+
# `_description:` declaration. When `agent_fields` is declared, the
|
|
211
|
+
# check is scoped to the allowlist (those are the agent-visible
|
|
212
|
+
# fields and the ones the LLM will see); otherwise it covers every
|
|
213
|
+
# declared property on the class.
|
|
214
|
+
#
|
|
215
|
+
# Excludes ALWAYS_PRESENT_FIELDS (the four system columns) since
|
|
216
|
+
# those don't benefit from per-property descriptions.
|
|
217
|
+
def missing_field_descriptions_for(klass)
|
|
218
|
+
return [] unless klass.respond_to?(:property_descriptions)
|
|
219
|
+
return [] unless klass.respond_to?(:field_map)
|
|
220
|
+
|
|
221
|
+
described = klass.property_descriptions.keys.map(&:to_sym).to_set
|
|
222
|
+
declared_properties = klass.field_map.keys.map(&:to_sym)
|
|
223
|
+
|
|
224
|
+
candidates =
|
|
225
|
+
if klass.respond_to?(:agent_field_allowlist) && klass.agent_field_allowlist.any?
|
|
226
|
+
klass.agent_field_allowlist.map(&:to_sym)
|
|
227
|
+
else
|
|
228
|
+
declared_properties
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
candidates - described.to_a - ALWAYS_PRESENT_FIELDS
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# `agent_fields` entries that don't resolve to a known property on
|
|
235
|
+
# the class. These would silently miss after the 4.2.1 wire-name
|
|
236
|
+
# translation — the symbol would columnize to a column the schema
|
|
237
|
+
# doesn't carry, and the filter would strip nothing.
|
|
238
|
+
def unresolvable_allowlist_entries_for(klass)
|
|
239
|
+
return [] unless klass.respond_to?(:agent_field_allowlist)
|
|
240
|
+
allowlist = klass.agent_field_allowlist
|
|
241
|
+
return [] if allowlist.empty?
|
|
242
|
+
return [] unless klass.respond_to?(:field_map)
|
|
243
|
+
|
|
244
|
+
known = klass.field_map.keys.map(&:to_sym).to_set
|
|
245
|
+
allowlist.map(&:to_sym).reject { |sym| known.include?(sym) }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
class << self
|
|
250
|
+
# Convenience class-method form of {Parse::Agent::MetadataAudit#audit}.
|
|
251
|
+
# See {MetadataAudit} for the full contract.
|
|
252
|
+
#
|
|
253
|
+
# @return [Hash] structured audit findings
|
|
254
|
+
def audit_metadata
|
|
255
|
+
Parse::Agent::MetadataAudit.audit
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|