Skip to content

Commit ebcac31

Browse files
committed
Release parse-stack-next v5.0.0
Major release preparing parse-stack-next v5.0.0 with API, tooling, and infra updates. Renames the gem to parse-stack-next and updates docs/README and changelog. Introduces an ergonomic Redis cache with a ConnectionPool-backed Pool wrapper and optional cache namespacing, plus ActiveSupport cache events and graceful pool-timeout handling. Adds vector/embeddings and vector-search primitives, a GraphQL type generator for Parse::Object models (opt-in), and many model/vector/graphQL helpers. Improves Ruby 3 compatibility by replacing class variables with class-instance state guarded by mutexes/monitors, refactors LiveQuery request id generation, and tightens various validations. MCP transport and agent code add health checks, protocol-version validation, and structured tool output. Also adds MongoDB instrumentation events and a slow-query subscriber hook. Includes CI docs workflow (YARD) and a large suite of new/updated tests to cover the new features and integrations.
1 parent 8f6d9fd commit ebcac31

69 files changed

Lines changed: 7900 additions & 169 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docs.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Publish YARD Docs
2+
3+
on:
4+
push:
5+
branches: [master]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
pages: write
11+
id-token: write
12+
13+
concurrency:
14+
group: pages
15+
cancel-in-progress: false
16+
17+
jobs:
18+
build:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
- uses: ruby/setup-ruby@v1
23+
with:
24+
ruby-version: '3.4'
25+
bundler-cache: true
26+
- name: Build YARD docs
27+
run: bundle exec rake yard
28+
- uses: actions/upload-pages-artifact@v3
29+
with:
30+
path: doc/parse-stack
31+
deploy:
32+
needs: build
33+
runs-on: ubuntu-latest
34+
environment:
35+
name: github-pages
36+
url: ${{ steps.deployment.outputs.page_url }}
37+
steps:
38+
- id: deployment
39+
uses: actions/deploy-pages@v4

CHANGELOG.md

Lines changed: 97 additions & 0 deletions
Large diffs are not rendered by default.

Gemfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
source "https://rubygems.org"
22

3-
# Specify your gem's dependencies in parse-stack.gemspec
4-
gemspec
3+
# Specify your gem's dependencies in parse-stack-next.gemspec
4+
gemspec name: "parse-stack-next"
55

66
group :test, :development do
77
gem "dotenv"

Gemfile.lock

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
PATH
22
remote: .
33
specs:
4-
parse-stack (4.4.3)
4+
parse-stack-next (5.0.0)
55
activemodel (>= 6.1, < 9)
66
activesupport (>= 6.1, < 9)
7+
connection_pool (>= 2.2, < 4)
78
csv (~> 3.3)
89
faraday (~> 2.0)
910
faraday-net_http_persistent (~> 2.0)
@@ -50,11 +51,16 @@ GEM
5051
faraday-net_http (>= 2.0, < 3.5)
5152
json
5253
logger
53-
faraday-net_http (3.4.2)
54+
faraday-net_http (3.4.3)
5455
net-http (~> 0.5)
5556
faraday-net_http_persistent (2.3.1)
5657
faraday (~> 2.5)
5758
net-http-persistent (>= 4.0.4, < 5)
59+
fiber-storage (1.0.1)
60+
graphql (2.6.2)
61+
base64
62+
fiber-storage
63+
logger
5864
i18n (1.14.8)
5965
concurrent-ruby (~> 1.0)
6066
io-console (0.8.2)
@@ -155,11 +161,12 @@ PLATFORMS
155161
DEPENDENCIES
156162
debug (>= 1.0)
157163
dotenv
164+
graphql (~> 2.0)
158165
minitest
159166
minitest-mock
160167
minitest-reporters
161168
mongo
162-
parse-stack!
169+
parse-stack-next!
163170
pry
164171
puma
165172
rack-test

README.md

Lines changed: 51 additions & 45 deletions
Large diffs are not rendered by default.

docs/mcp_guide.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ Cooperative cancellation lets clients abort an in-flight long-running tool call.
285285

286286
2. **SSE client disconnect.** When the underlying TCP connection closes (browser tab closed, network drop), Rack calls `SSEBody#close`, which trips the same cancellation token.
287287

288-
**Identity binding (required for `notifications/cancelled`).** The cancelling request **must** carry the same `X-MCP-Session-Id` header as the original request. The header is sanitized into `agent.correlation_id` and used as half of the registry key (the JSON-RPC `requestId` is the other half). Cancellation without a matching `X-MCP-Session-Id` is a silent no-op — this prevents an attacker who guesses sequential JSON-RPC ids from cancelling other clients' in-flight requests. Failures (no session id, no matching entry, mismatched session id) all return `202` so the response shape is not a probe oracle.
288+
**Identity binding (required for `notifications/cancelled`).** The cancelling request **must** carry the same `Mcp-Session-Id` header as the original request. The header is sanitized into `agent.correlation_id` and used as half of the registry key (the JSON-RPC `requestId` is the other half). Cancellation without a matching `Mcp-Session-Id` is a silent no-op — this prevents an attacker who guesses sequential JSON-RPC ids from cancelling other clients' in-flight requests. Failures (no session id, no matching entry, mismatched session id) all return `202` so the response shape is not a probe oracle.
289289

290290
**Cooperative checkpoints.** Cancellation is observed at safe points inside tool execution, not by forcibly killing the dispatcher thread. The two checkpoints built into `Parse::Agent#execute` are:
291291

@@ -319,7 +319,7 @@ The stream still emits the `response` SSE event before closing so clients do not
319319

320320
**Scope and limitations.**
321321
- The cancellation registry is per `MCPRackApp` instance. Cancellation does not span multiple mount points within a process, nor multiple processes in a clustered deployment.
322-
- Clients that do not set `X-MCP-Session-Id` lose cancellation but keep every other MCP feature.
322+
- Clients that do not set `Mcp-Session-Id` lose cancellation but keep every other MCP feature.
323323
- The standalone WEBrick-backed `MCPServer` does not support streaming and therefore does not support cancellation; calls return a single buffered response with no opportunity to interrupt.
324324

325325
---
@@ -1834,7 +1834,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
18341834

18351835
**Conversation correlation across multi-tool sessions.** Without correlation, individual tool-call events have no link between them — a Datadog dashboard sees "user X did query_class" and "user X did get_object" as independent points, with no way to know they belong to the same LLM turn. The dispatcher threads an optional correlation id through to every notification:
18361836

1837-
- **Header path (recommended for hosted MCP):** the client sends `X-MCP-Session-Id: <opaque-id>` on every request in the conversation. `MCPRackApp` reads the header, sanitizes the value (charset `[A-Za-z0-9._-]`, max 128 chars — anything else is silently dropped to prevent log injection), and sets `agent.correlation_id` unless the factory has already supplied one. Notifications fired during that request carry the value as `payload[:correlation_id]`.
1837+
- **Header path (recommended for hosted MCP):** the client sends `Mcp-Session-Id: <opaque-id>` on every request in the conversation (the MCP 2025-06-18 Streamable HTTP spec-canonical name). `MCPRackApp` reads the header, sanitizes the value (charset `[A-Za-z0-9._-]`, max 128 chars — anything else is silently dropped to prevent log injection), and sets `agent.correlation_id` unless the factory has already supplied one. Notifications fired during that request carry the value as `payload[:correlation_id]`.
18381838

18391839
- **Factory path (for application-bound sessions):** application code that already has an internal session identifier can override the client-supplied header by setting it inside the agent factory:
18401840

@@ -1853,7 +1853,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
18531853

18541854
When unset (no header, no factory assignment), `payload[:correlation_id]` is omitted entirely — the key does not appear in the payload hash.
18551855

1856-
The same `X-MCP-Session-Id` header is **required** for cooperative cancellation via `notifications/cancelled` — see the Cancellation section. Clients that thread the header through every request in a conversation get both correlated audit logs and cancellation; clients that don't lose both but keep every other MCP feature.
1856+
The same `Mcp-Session-Id` header is **required** for cooperative cancellation via `notifications/cancelled` — see the Cancellation section. Clients that thread the header through every request in a conversation get both correlated audit logs and cancellation; clients that don't lose both but keep every other MCP feature.
18571857

18581858
**Cancellation notification asymmetry.** A tool cancelled BEFORE it runs (via `agent.cancelled?` at the dispatcher's first checkpoint) does not fire `parse.agent.tool_call` — the tool never executed, so there is nothing to instrument. This matches how rate-limit and permission refusals are surfaced. A tool cancelled AFTER it returns (second checkpoint, "client cancelled while the tool's I/O was running") DOES fire the notification with `success: false, error_code: :cancelled`. Subscribers that count cancellations should expect the second shape; pre-run cancellations are visible to operators only via the wire response.
18591859

lib/parse-stack-next.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# encoding: UTF-8
2+
# frozen_string_literal: true
3+
4+
# Auto-required entry point for the `parse-stack-next` gem.
5+
require_relative "./parse/stack.rb"

lib/parse/agent.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ def assert_llm_endpoint_allowed!(endpoint)
604604

605605
# @return [String, nil] caller-supplied identifier that ties multiple
606606
# tool calls into a single logical conversation. Set by the transport
607-
# layer (MCPRackApp reads X-MCP-Session-Id) or directly by an
607+
# layer (MCPRackApp reads Mcp-Session-Id) or directly by an
608608
# embedder. Included in every `parse.agent.tool_call` notification
609609
# payload as `:correlation_id` when present. Sanitized to a max of
610610
# 128 characters from the set `[A-Za-z0-9._-]` to prevent log

lib/parse/agent/mcp_rack_app.rb

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -188,14 +188,30 @@ def self.strip_underscore_smuggled_headers!(env)
188188
# CSRF and force a CORS preflight on browser `fetch()`, so this
189189
# gate closes the browser-driven attack surface entirely. Pair
190190
# with `allowed_origins` for defense in depth.
191+
# @param health_path [String, nil] when set (e.g. `"/health"`),
192+
# `GET` requests to that exact path return `200 {"status":"ok"}`
193+
# without invoking the agent_factory, without authentication,
194+
# without rate-limiting, and without applying the
195+
# `allowed_origins` / `require_custom_header` CSRF gates.
196+
# Intended as a liveness probe for load balancers and
197+
# orchestrators (Kubernetes, ECS, Consul) that cannot present a
198+
# matching `Origin` or custom header. Because the probe sits
199+
# ahead of the pre-auth rate limiter, operators should
200+
# front-edge rate-limit the path at the LB/Nginx layer if
201+
# public-facing. The response intentionally contains no
202+
# version, build, or counter information — fingerprint-minimal
203+
# by design. `nil` (default) disables the endpoint entirely;
204+
# empty-string values are coerced to `nil`. Any non-GET method
205+
# on the path falls through to the standard 405 handler.
191206
# @raise [ArgumentError] if both or neither of agent_factory/block are given.
192207
def initialize(agent_factory: nil, max_body_size: DEFAULT_MAX_BODY_SIZE,
193208
logger: nil, streaming: false,
194209
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
195210
max_concurrent_dispatchers: nil,
196211
pre_auth_rate_limiter: nil,
197212
allowed_origins: nil,
198-
require_custom_header: nil, &block)
213+
require_custom_header: nil,
214+
health_path: nil, &block)
199215
if agent_factory && block
200216
raise ArgumentError, "Provide agent_factory: OR a block, not both"
201217
end
@@ -215,6 +231,7 @@ def initialize(agent_factory: nil, max_body_size: DEFAULT_MAX_BODY_SIZE,
215231
@pre_auth_rate_limiter = pre_auth_rate_limiter
216232
@allowed_origins = normalize_allowed_origins(allowed_origins)
217233
@required_custom_header = normalize_required_custom_header(require_custom_header)
234+
@health_path = health_path.is_a?(String) && !health_path.empty? ? health_path : nil
218235
# Per-app registry of in-flight cancellable requests. Keyed by
219236
# [correlation_id, request_id]. A `notifications/cancelled` POST
220237
# whose `params.requestId` matches an entry trips the registered
@@ -267,6 +284,15 @@ def call(env)
267284
# underscored form collapses-and-overwrites the trusted slot.
268285
self.class.strip_underscore_smuggled_headers!(env)
269286

287+
# 0a. Liveness probe. When `health_path:` is configured, a GET to
288+
# that exact path returns `{"status":"ok"}` without auth,
289+
# rate-limiting, or factory invocation. Intentionally
290+
# fingerprint-minimal: no version, no build, no counter —
291+
# a load balancer needs "is it up?", not "what is it?".
292+
if @health_path && env["PATH_INFO"] == @health_path && env["REQUEST_METHOD"] == "GET"
293+
return [200, json_headers, ['{"status":"ok"}']]
294+
end
295+
270296
# 0b. NEW-MCP-6: pre-auth rate limit. Runs BEFORE the agent_factory
271297
# so a malformed body / missing key / empty `{}` cannot force
272298
# the operator-supplied factory to round-trip to Parse Server
@@ -348,6 +374,32 @@ def call(env)
348374
return [400, json_headers, [json_rpc_error(-32_600, "Invalid Request")]]
349375
end
350376

377+
# 4c. MCP-Protocol-Version header validation (MCP 2025-06-18
378+
# Streamable HTTP). The spec says:
379+
# - The client MUST send `MCP-Protocol-Version: <ver>`
380+
# on every request AFTER initialize.
381+
# - If absent on a non-initialize request, the server
382+
# SHOULD assume `2025-03-26` for backwards compatibility.
383+
# - If present but not a version the server supports,
384+
# the server MUST respond `400 Bad Request`.
385+
# Initialize requests are exempt — initialize IS the
386+
# negotiation, so the header is meaningless there.
387+
# Cancellation notifications are also exempt because they
388+
# may be sent by a client that has not (yet) completed
389+
# initialize against this transport instance (e.g. a
390+
# reconnecting client cancelling a pre-disconnect request).
391+
unless body["method"] == "initialize" ||
392+
body["method"] == "notifications/cancelled"
393+
requested = env["HTTP_MCP_PROTOCOL_VERSION"]
394+
if requested.is_a?(String) && !requested.empty? &&
395+
!Parse::Agent::MCPDispatcher::SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
396+
return [400, json_headers,
397+
[json_rpc_error(-32_600,
398+
"Unsupported MCP-Protocol-Version: #{requested}",
399+
id: body["id"])]]
400+
end
401+
end
402+
351403
# 5. Agent factory — auth gate. Rescue Unauthorized first, then catch-all
352404
# for unexpected factory errors.
353405
begin
@@ -363,24 +415,27 @@ def call(env)
363415
return [500, json_headers, [json_rpc_error(-32_603, "Internal error")]]
364416
end
365417

366-
# 5b. Thread the conversation correlation id through. Source:
367-
# X-MCP-Session-Id header. Only fills it when the factory
368-
# hasn't already assigned one — application code that needs to
369-
# override the client-supplied id (e.g., bind to an internal
370-
# session record) can do so in the factory and we don't
371-
# stomp on it. The Parse::Agent#correlation_id= setter
372-
# sanitizes the value; an invalid header is silently dropped.
418+
# 5b. Thread the conversation correlation id through. Source
419+
# header: the MCP 2025-06-18 Streamable HTTP spec-canonical
420+
# `Mcp-Session-Id` (Rack env key `HTTP_MCP_SESSION_ID`).
421+
#
422+
# Only fills it when the factory hasn't already assigned one
423+
# — application code that needs to override the
424+
# client-supplied id (e.g., bind to an internal session
425+
# record) can do so in the factory and we don't stomp on it.
426+
# The Parse::Agent#correlation_id= setter sanitizes the
427+
# value; an invalid header is silently dropped.
373428
if agent && agent.respond_to?(:correlation_id=) &&
374429
agent.correlation_id.nil? &&
375-
(sid = env["HTTP_X_MCP_SESSION_ID"])
430+
(sid = env["HTTP_MCP_SESSION_ID"])
376431
agent.correlation_id = sid
377432
end
378433

379434
# 5c. notifications/cancelled — special-cased BEFORE the dispatcher.
380435
# A JSON-RPC notification has no `id`, expects no response
381436
# body, and must trip the in-flight request whose
382437
# `(correlation_id, request_id)` matches. We require the
383-
# cancelling request to carry the same X-MCP-Session-Id
438+
# cancelling request to carry the same Mcp-Session-Id
384439
# (sanitized into agent.correlation_id above) as the original
385440
# request — otherwise an attacker who guesses sequential
386441
# JSON-RPC ids could cancel arbitrary in-flight requests.
@@ -945,7 +1000,7 @@ def build_error_envelope(error)
9451000
# matching CancellationToken.
9461001
#
9471002
# Identity binding: cancellation requires the cancelling request's
948-
# `X-MCP-Session-Id` (sanitized into `agent.correlation_id`) to
1003+
# `Mcp-Session-Id` (sanitized into `agent.correlation_id`) to
9491004
# match the original request's. This prevents an attacker who
9501005
# guesses sequential JSON-RPC request ids from cancelling other
9511006
# clients' in-flight requests. A registration with a nil

lib/parse/agent/pipeline_validator.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,22 @@ def initialize(message, stage: nil, reason: nil, operator: nil)
5151
# Validate an aggregation pipeline for security issues.
5252
# Delegates to {Parse::PipelineSecurity.validate_pipeline!} and
5353
# translates its error into {PipelineSecurityError} for backwards
54-
# compatibility.
54+
# compatibility. Additionally refuses Atlas-stage-0-only operators
55+
# (`$search`, `$searchMeta`, `$vectorSearch`, `$listSearchIndexes`)
56+
# which are legal SDK-emitted stages but must NOT appear in a
57+
# caller-supplied agent pipeline — the agent surface for those is
58+
# the dedicated `atlas_search` / `semantic_search` tools, and the
59+
# Agent's tenant-scope `$match` prepend would push them off
60+
# stage 0 anyway. See
61+
# {Parse::PipelineSecurity::STAGE0_ONLY_ATLAS_STAGES}.
5562
#
5663
# @param pipeline [Array<Hash>] the aggregation pipeline stages
5764
# @raise [PipelineSecurityError] if pipeline contains blocked or unknown stages
5865
# @return [true] if pipeline is valid
5966
def validate!(pipeline)
6067
Parse::PipelineSecurity.validate_pipeline!(pipeline)
68+
refuse_stage0_only_atlas_stages!(pipeline)
69+
true
6170
rescue Parse::PipelineSecurity::Error => e
6271
raise PipelineSecurityError.new(
6372
e.message,
@@ -67,6 +76,24 @@ def validate!(pipeline)
6776
)
6877
end
6978

79+
# @api private
80+
def refuse_stage0_only_atlas_stages!(pipeline)
81+
return unless pipeline.is_a?(Array)
82+
pipeline.each do |stage|
83+
next unless stage.is_a?(Hash)
84+
stage.each_key do |k|
85+
key = k.to_s
86+
next unless Parse::PipelineSecurity::STAGE0_ONLY_ATLAS_STAGES.include?(key)
87+
raise PipelineSecurityError.new(
88+
"Stage #{key} is not allowed in caller-supplied agent pipelines. " \
89+
"Use the dedicated atlas_search / semantic_search agent tool instead.",
90+
stage: key,
91+
reason: :stage0_only_atlas_stage,
92+
)
93+
end
94+
end
95+
end
96+
7097
# Check if a pipeline is valid without raising.
7198
#
7299
# @param pipeline [Array<Hash>] the aggregation pipeline

0 commit comments

Comments
 (0)