parse-stack-next 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/classes/role.rb +227 -28
  75. data/lib/parse/model/classes/user.rb +60 -1
  76. data/lib/parse/model/clp.rb +4 -4
  77. data/lib/parse/model/core/actions.rb +97 -15
  78. data/lib/parse/model/core/create_lock.rb +0 -2
  79. data/lib/parse/model/core/describe.rb +57 -57
  80. data/lib/parse/model/core/embed_managed.rb +21 -23
  81. data/lib/parse/model/core/fetching.rb +2 -0
  82. data/lib/parse/model/core/indexing.rb +14 -14
  83. data/lib/parse/model/core/parse_reference.rb +1 -1
  84. data/lib/parse/model/core/properties.rb +2 -2
  85. data/lib/parse/model/core/querying.rb +1 -1
  86. data/lib/parse/model/core/schema.rb +2 -2
  87. data/lib/parse/model/core/search_indexing.rb +2 -2
  88. data/lib/parse/model/core/vector_searchable.rb +4 -5
  89. data/lib/parse/model/file.rb +21 -15
  90. data/lib/parse/model/geojson.rb +2 -2
  91. data/lib/parse/model/geopoint.rb +1 -0
  92. data/lib/parse/model/object.rb +132 -66
  93. data/lib/parse/model/pointer.rb +2 -0
  94. data/lib/parse/model/polygon.rb +3 -6
  95. data/lib/parse/model/push.rb +2 -2
  96. data/lib/parse/model/vector.rb +3 -1
  97. data/lib/parse/mongodb.rb +512 -282
  98. data/lib/parse/pipeline_security.rb +3 -0
  99. data/lib/parse/query/constraints.rb +46 -46
  100. data/lib/parse/query.rb +146 -59
  101. data/lib/parse/retrieval/agent_tool.rb +34 -20
  102. data/lib/parse/retrieval/chunk.rb +1 -0
  103. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  104. data/lib/parse/retrieval/reranker.rb +3 -4
  105. data/lib/parse/retrieval/retriever.rb +4 -6
  106. data/lib/parse/schema/index_migrator.rb +26 -26
  107. data/lib/parse/schema/search_index_migrator.rb +19 -19
  108. data/lib/parse/stack/tasks.rb +6 -6
  109. data/lib/parse/stack/version.rb +1 -1
  110. data/lib/parse/stack.rb +10 -10
  111. data/lib/parse/vector_search/hybrid.rb +66 -41
  112. data/lib/parse/vector_search.rb +14 -11
  113. data/lib/parse/webhooks/payload.rb +1 -0
  114. data/lib/parse/webhooks/registration.rb +4 -4
  115. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  116. data/lib/parse/webhooks.rb +22 -4
  117. data/parse-stack-next.gemspec +6 -6
  118. metadata +10 -1
@@ -68,9 +68,9 @@ module Parse
68
68
  # they still see the latest registry state on the next
69
69
  # `tools/list` / `prompts/list` poll.
70
70
  CAPABILITIES = {
71
- "tools" => { "listChanged" => true },
72
- "resources" => { "subscribe" => false, "listChanged" => false },
73
- "prompts" => { "listChanged" => true },
71
+ "tools" => { "listChanged" => true },
72
+ "resources" => { "subscribe" => false, "listChanged" => false },
73
+ "prompts" => { "listChanged" => true },
74
74
  }.freeze
75
75
 
76
76
  # Parse class-name identifier regex — used to validate resource URIs.
@@ -150,9 +150,9 @@ module Parse
150
150
  # dispatches on the same shared agent would race: the second
151
151
  # request's ensure would null the first request's still-needed
152
152
  # token.
153
- prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
154
- prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
155
- prev_approval_gate = agent.approval_gate if agent.respond_to?(:approval_gate)
153
+ prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
154
+ prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
155
+ prev_approval_gate = agent.approval_gate if agent.respond_to?(:approval_gate)
156
156
 
157
157
  # Install the progress callback and cancellation token on the
158
158
  # agent for the duration of the dispatch. Cleared in the ensure
@@ -164,12 +164,12 @@ module Parse
164
164
  # two threads concurrently — the snapshot-restore pattern here
165
165
  # only handles sequential interleave. MCPRackApp's `agent_factory:`
166
166
  # is documented to return a fresh agent per request.
167
- agent.progress_callback = progress_callback if progress_callback && agent.respond_to?(:progress_callback=)
168
- agent.cancellation_token = cancellation_token if cancellation_token && agent.respond_to?(:cancellation_token=)
167
+ agent.progress_callback = progress_callback if progress_callback && agent.respond_to?(:progress_callback=)
168
+ agent.cancellation_token = cancellation_token if cancellation_token && agent.respond_to?(:cancellation_token=)
169
169
  # Install the per-session approval gate (MCP elicitation) so
170
170
  # agent.execute can request human approval for destructive tools.
171
171
  # Restored in the ensure block like the other per-request state.
172
- agent.approval_gate = approval_gate if approval_gate && agent.respond_to?(:approval_gate=)
172
+ agent.approval_gate = approval_gate if approval_gate && agent.respond_to?(:approval_gate=)
173
173
 
174
174
  # Guard: body must be a Hash with a "method" key.
175
175
  unless body.is_a?(Hash) && body.key?("method")
@@ -179,7 +179,7 @@ module Parse
179
179
 
180
180
  method = body["method"]
181
181
  params = body["params"] || {}
182
- id = body["id"]
182
+ id = body["id"]
183
183
 
184
184
  # JSON-RPC notifications MUST NOT carry an `id` field. Reject
185
185
  # `notifications/*` methods that include one — silently treating
@@ -191,7 +191,6 @@ module Parse
191
191
 
192
192
  result_hash = dispatch(method, params, agent, id, logger, subscription_manager)
193
193
  { status: result_hash[:status], body: result_hash[:body] }
194
-
195
194
  rescue Parse::Agent::Unauthorized => e
196
195
  { status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") }
197
196
  rescue StandardError => e
@@ -206,7 +205,7 @@ module Parse
206
205
  # this request's token visible to a sibling dispatch on a
207
206
  # shared agent.
208
207
  if agent.respond_to?(:progress_callback=)
209
- agent.progress_callback = prev_progress_callback
208
+ agent.progress_callback = prev_progress_callback
210
209
  end
211
210
  if agent.respond_to?(:cancellation_token=)
212
211
  agent.cancellation_token = prev_cancellation_token
@@ -296,7 +295,6 @@ module Parse
296
295
  else
297
296
  { status: 200, body: jsonrpc_envelope(id, result: result) }
298
297
  end
299
-
300
298
  rescue Parse::Agent::Unauthorized => e
301
299
  { status: 401, body: jsonrpc_error(id, -32001, "Unauthorized") }
302
300
  rescue Parse::Agent::AccessDenied
@@ -340,17 +338,16 @@ module Parse
340
338
  # @return [Hash] protocol version, capabilities, and server info.
341
339
  def self.handle_initialize(params, subscription_manager = nil)
342
340
  requested = params.is_a?(Hash) ? params["protocolVersion"] : nil
343
- negotiated =
344
- if requested.is_a?(String) && SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
341
+ negotiated = if requested.is_a?(String) && SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
345
342
  requested
346
343
  else
347
344
  PROTOCOL_VERSION
348
345
  end
349
346
  {
350
347
  "protocolVersion" => negotiated,
351
- "capabilities" => capabilities_for(subscription_manager),
352
- "serverInfo" => {
353
- "name" => "parse-stack-mcp",
348
+ "capabilities" => capabilities_for(subscription_manager),
349
+ "serverInfo" => {
350
+ "name" => "parse-stack-mcp",
354
351
  "version" => Parse::Stack::VERSION,
355
352
  },
356
353
  }
@@ -415,7 +412,7 @@ module Parse
415
412
  end
416
413
 
417
414
  sym_args = arguments.transform_keys(&:to_sym)
418
- result = agent.execute(tool_name.to_sym, **sym_args)
415
+ result = agent.execute(tool_name.to_sym, **sym_args)
419
416
 
420
417
  # Cancellation short-circuit. Tools cooperate by returning a
421
418
  # `success: false, cancelled: true` envelope when `agent.cancelled?`
@@ -426,12 +423,12 @@ module Parse
426
423
  # still honor the client's intent by not surfacing the result).
427
424
  if result[:cancelled] || (agent.respond_to?(:cancelled?) && agent.cancelled?)
428
425
  return {
429
- "content" => [
430
- { "type" => "text", "text" => (result[:error] || "Cancelled by client").to_s },
431
- ],
432
- "isError" => true,
433
- "cancelled" => true,
434
- }
426
+ "content" => [
427
+ { "type" => "text", "text" => (result[:error] || "Cancelled by client").to_s },
428
+ ],
429
+ "isError" => true,
430
+ "cancelled" => true,
431
+ }
435
432
  end
436
433
 
437
434
  if result[:success]
@@ -443,8 +440,7 @@ module Parse
443
440
  # with a _truncated block. Models handle partial success much
444
441
  # better than full refusal — they continue the task instead of
445
442
  # restarting. Other tools fall through to the structural refusal.
446
- recovered_text =
447
- if %w[query_class get_objects aggregate].include?(tool_name)
443
+ recovered_text = if %w[query_class get_objects aggregate].include?(tool_name)
448
444
  attempt_truncate_response(result[:data], MAX_TOOL_RESPONSE_BYTES, tool_name)
449
445
  end
450
446
 
@@ -502,9 +498,9 @@ module Parse
502
498
  "isError" => true,
503
499
  }
504
500
  meta = {}
505
- meta["parse.error_code"] = result[:error_code].to_s if result[:error_code]
506
- meta["parse.retry_after"] = result[:retry_after] if result[:retry_after]
507
- meta["parse.details"] = result[:details] if result[:details].is_a?(Hash) && result[:details].any?
501
+ meta["parse.error_code"] = result[:error_code].to_s if result[:error_code]
502
+ meta["parse.retry_after"] = result[:retry_after] if result[:retry_after]
503
+ meta["parse.details"] = result[:details] if result[:details].is_a?(Hash) && result[:details].any?
508
504
  envelope["_meta"] = meta unless meta.empty?
509
505
  envelope
510
506
  end
@@ -539,14 +535,7 @@ module Parse
539
535
  # narrowing guidance carry the message.
540
536
  return nil if data[:output].is_a?(String) && data[:headers].is_a?(Array)
541
537
 
542
- rows =
543
- if data[:results].is_a?(Array) then data[:results]
544
- elsif data["results"].is_a?(Array) then data["results"]
545
- elsif data[:objects].is_a?(Hash) then data[:objects].values
546
- elsif data["objects"].is_a?(Hash) then data["objects"].values
547
- elsif data[:object].is_a?(Hash) then [data[:object]]
548
- elsif data["object"].is_a?(Hash) then [data["object"]]
549
- end
538
+ rows = if data[:results].is_a?(Array) then data[:results] elsif data["results"].is_a?(Array) then data["results"] elsif data[:objects].is_a?(Hash) then data[:objects].values elsif data["objects"].is_a?(Hash) then data["objects"].values elsif data[:object].is_a?(Hash) then [data[:object]] elsif data["object"].is_a?(Hash) then [data["object"]] end
550
539
 
551
540
  return nil unless rows.is_a?(Array) && rows.any?
552
541
 
@@ -566,10 +555,10 @@ module Parse
566
555
 
567
556
  return nil if bytes_per_field.empty?
568
557
 
569
- sorted = bytes_per_field.sort_by { |_, b| -b }
570
- top = sorted.first(3)
571
- n = sample.size.to_f
572
- largest = sorted.first[0]
558
+ sorted = bytes_per_field.sort_by { |_, b| -b }
559
+ top = sorted.first(3)
560
+ n = sample.size.to_f
561
+ largest = sorted.first[0]
573
562
 
574
563
  # Produce a POSITIVE keys: list rather than asking the LLM to
575
564
  # subtract. `keys:` is inclusive — models that see "excluding 'X'"
@@ -581,7 +570,7 @@ module Parse
581
570
 
582
571
  formatted = top.map { |k, b| "#{k} (~#{humanize_bytes(b / n)}/record)" }.join(", ")
583
572
  "Largest fields by bytes: #{formatted}. " \
584
- "Try keys: #{keep_fields.join(",").inspect} (drops the heaviest field)."
573
+ "Try keys: #{keep_fields.join(",").inspect} (drops the heaviest field)."
585
574
  end
586
575
  private_class_method :diagnose_oversize
587
576
 
@@ -661,7 +650,7 @@ module Parse
661
650
  rows = data[:results] || data["results"]
662
651
  return nil unless rows.is_a?(Array) && rows.any?
663
652
 
664
- sample = rows.first(5).select { |r| r.is_a?(Hash) }
653
+ sample = rows.first(5).select { |r| r.is_a?(Hash) }
665
654
  return nil if sample.empty?
666
655
 
667
656
  heaviest = find_heaviest_field(sample)
@@ -676,7 +665,7 @@ module Parse
676
665
  # Read the caller's effective skip so next_skip can resume rather
677
666
  # than reset pagination. Only relevant for query_class; aggregate
678
667
  # pipelines are deterministic and not paginatable.
679
- pagination = data[:pagination] || data["pagination"] || {}
668
+ pagination = data[:pagination] || data["pagination"] || {}
680
669
  original_skip = (pagination[:skip] || pagination["skip"] || 0).to_i
681
670
 
682
671
  # The recovered envelope must strip stale cardinality keys so the
@@ -708,8 +697,7 @@ module Parse
708
697
  candidate.delete(:next_call)
709
698
  candidate.delete("next_call")
710
699
 
711
- initial_hint =
712
- if tool_name == "aggregate"
700
+ initial_hint = if tool_name == "aggregate"
713
701
  "Field '#{heaviest}' was dropped from all rows to fit the #{max_bytes}-byte response cap. " \
714
702
  "Narrow the pipeline with a $match or $project stage to reduce result size, " \
715
703
  "or call get_object(class_name: <class>, object_id: <id>) for the dropped field."
@@ -719,15 +707,15 @@ module Parse
719
707
  end
720
708
 
721
709
  annotation = {
722
- reason: "response_exceeded_max_bytes",
710
+ reason: "response_exceeded_max_bytes",
723
711
  dropped_fields: [heaviest],
724
- kept_count: trimmed_rows.size,
712
+ kept_count: trimmed_rows.size,
725
713
  original_count: rows.size,
726
- hint: initial_hint,
714
+ hint: initial_hint,
727
715
  }
728
716
 
729
717
  # First try: heaviest field dropped, all rows kept.
730
- candidate[:results] = trimmed_rows
718
+ candidate[:results] = trimmed_rows
731
719
  candidate[:_truncated] = annotation
732
720
  text = JSON.pretty_generate(candidate)
733
721
  return text if text.bytesize <= max_bytes
@@ -736,10 +724,10 @@ module Parse
736
724
  # from the trimmed sample, then verify and back off by one if the
737
725
  # estimator overshoots (JSON overhead).
738
726
  sample_after = trimmed_rows.first([5, trimmed_rows.size].min)
739
- sample_text = JSON.pretty_generate(sample_after)
740
- per_row = sample_text.bytesize / sample_after.size.to_f
727
+ sample_text = JSON.pretty_generate(sample_after)
728
+ per_row = sample_text.bytesize / sample_after.size.to_f
741
729
 
742
- envelope_data = candidate.merge(results: [])
730
+ envelope_data = candidate.merge(results: [])
743
731
  envelope_bytes = JSON.pretty_generate(envelope_data).bytesize
744
732
  budget = max_bytes - envelope_bytes - 256 # safety margin
745
733
  return nil if budget <= 0 || per_row <= 0
@@ -749,7 +737,7 @@ module Parse
749
737
  return nil if fit_count < 1
750
738
 
751
739
  loop do
752
- candidate[:results] = trimmed_rows.first(fit_count)
740
+ candidate[:results] = trimmed_rows.first(fit_count)
753
741
  candidate[:_truncated][:kept_count] = fit_count
754
742
 
755
743
  if tool_name == "query_class"
@@ -793,7 +781,7 @@ module Parse
793
781
  objects = data[:objects] || data["objects"]
794
782
  return nil unless objects.is_a?(Hash) && objects.any?
795
783
 
796
- sample = objects.values.first(5).select { |r| r.is_a?(Hash) }
784
+ sample = objects.values.first(5).select { |r| r.is_a?(Hash) }
797
785
  return nil if sample.empty?
798
786
 
799
787
  heaviest = find_heaviest_field(sample)
@@ -810,27 +798,27 @@ module Parse
810
798
  candidate = data.dup
811
799
 
812
800
  annotation = {
813
- reason: "response_exceeded_max_bytes",
814
- dropped_fields: [heaviest],
815
- kept_count: trimmed_objects.size,
816
- original_count: objects.size,
801
+ reason: "response_exceeded_max_bytes",
802
+ dropped_fields: [heaviest],
803
+ kept_count: trimmed_objects.size,
804
+ original_count: objects.size,
817
805
  dropped_for_size: [],
818
- hint: "Field '#{heaviest}' was dropped from all records to fit the #{max_bytes}-byte response cap. " \
819
- "To retrieve it for a specific record, call get_object(class_name: <class>, object_id: <id>).",
806
+ hint: "Field '#{heaviest}' was dropped from all records to fit the #{max_bytes}-byte response cap. " \
807
+ "To retrieve it for a specific record, call get_object(class_name: <class>, object_id: <id>).",
820
808
  }
821
809
 
822
810
  # First try: heaviest field dropped, all records kept.
823
- candidate[:objects] = trimmed_objects
811
+ candidate[:objects] = trimmed_objects
824
812
  candidate[:_truncated] = annotation
825
813
  text = JSON.pretty_generate(candidate)
826
814
  return text if text.bytesize <= max_bytes
827
815
 
828
816
  # Still over budget — drop trailing records by insertion order.
829
817
  sample_after = trimmed_objects.values.first([5, trimmed_objects.size].min)
830
- sample_text = JSON.pretty_generate(sample_after)
831
- per_rec = sample_text.bytesize / sample_after.size.to_f
818
+ sample_text = JSON.pretty_generate(sample_after)
819
+ per_rec = sample_text.bytesize / sample_after.size.to_f
832
820
 
833
- envelope_data = candidate.merge(objects: {})
821
+ envelope_data = candidate.merge(objects: {})
834
822
  envelope_bytes = JSON.pretty_generate(envelope_data).bytesize
835
823
  budget = max_bytes - envelope_bytes - 256 # safety margin
836
824
  return nil if budget <= 0 || per_rec <= 0
@@ -840,11 +828,11 @@ module Parse
840
828
  return nil if fit_count < 1
841
829
 
842
830
  loop do
843
- kept_keys = trimmed_objects.keys.first(fit_count)
831
+ kept_keys = trimmed_objects.keys.first(fit_count)
844
832
  dropped_keys = trimmed_objects.keys - kept_keys
845
833
 
846
- candidate[:objects] = trimmed_objects.slice(*kept_keys)
847
- candidate[:_truncated][:kept_count] = fit_count
834
+ candidate[:objects] = trimmed_objects.slice(*kept_keys)
835
+ candidate[:_truncated][:kept_count] = fit_count
848
836
  candidate[:_truncated][:dropped_for_size] = dropped_keys
849
837
  candidate[:_truncated][:hint] =
850
838
  "Field '#{heaviest}' was dropped and only #{fit_count} of #{objects.size} records fit " \
@@ -863,8 +851,8 @@ module Parse
863
851
  # @api private
864
852
  def self.humanize_bytes(n)
865
853
  n = n.to_f
866
- return "#{n.round} B" if n < 1024
867
- return "#{(n / 1024.0).round(1)} KB" if n < 1_048_576
854
+ return "#{n.round} B" if n < 1024
855
+ return "#{(n / 1024.0).round(1)} KB" if n < 1_048_576
868
856
  "#{(n / 1_048_576.0).round(1)} MB"
869
857
  end
870
858
  private_class_method :humanize_bytes
@@ -887,30 +875,30 @@ module Parse
887
875
  # every MCP client. We now concatenate `custom` and `built_in` and
888
876
  # fall back to the legacy `classes` key in case a custom agent
889
877
  # subclass returns the older shape.
890
- data = result[:data] || {}
891
- classes = (data[:custom] || []) + (data[:built_in] || [])
892
- classes = data[:classes] || [] if classes.empty? && data[:classes]
878
+ data = result[:data] || {}
879
+ classes = (data[:custom] || []) + (data[:built_in] || [])
880
+ classes = data[:classes] || [] if classes.empty? && data[:classes]
893
881
  resources = classes.flat_map do |cls|
894
- name = cls[:name]
882
+ name = cls[:name]
895
883
  klass_desc = cls[:description] || "Parse class (#{cls[:type] || "Custom"})"
896
884
  [
897
885
  {
898
- "uri" => "parse://#{name}/schema",
899
- "name" => "#{name} schema",
886
+ "uri" => "parse://#{name}/schema",
887
+ "name" => "#{name} schema",
900
888
  "description" => "Field definitions and types for #{name}. #{klass_desc}",
901
- "mimeType" => "application/json",
889
+ "mimeType" => "application/json",
902
890
  },
903
891
  {
904
- "uri" => "parse://#{name}/count",
905
- "name" => "#{name} count",
892
+ "uri" => "parse://#{name}/count",
893
+ "name" => "#{name} count",
906
894
  "description" => "Total number of #{name} objects",
907
- "mimeType" => "application/json",
895
+ "mimeType" => "application/json",
908
896
  },
909
897
  {
910
- "uri" => "parse://#{name}/samples",
911
- "name" => "#{name} samples",
898
+ "uri" => "parse://#{name}/samples",
899
+ "name" => "#{name} samples",
912
900
  "description" => "Five most recent #{name} objects",
913
- "mimeType" => "application/json",
901
+ "mimeType" => "application/json",
914
902
  },
915
903
  ]
916
904
  end
@@ -939,21 +927,21 @@ module Parse
939
927
  "resourceTemplates" => [
940
928
  {
941
929
  "uriTemplate" => "parse://{className}/schema",
942
- "name" => "Parse class schema",
930
+ "name" => "Parse class schema",
943
931
  "description" => "Field definitions and types for a Parse class. Expand {className} with any class your agent can list via tools/list or resources/list.",
944
- "mimeType" => "application/json",
932
+ "mimeType" => "application/json",
945
933
  },
946
934
  {
947
935
  "uriTemplate" => "parse://{className}/count",
948
- "name" => "Parse class object count",
936
+ "name" => "Parse class object count",
949
937
  "description" => "Total number of objects in a Parse class.",
950
- "mimeType" => "application/json",
938
+ "mimeType" => "application/json",
951
939
  },
952
940
  {
953
941
  "uriTemplate" => "parse://{className}/samples",
954
- "name" => "Parse class sample objects",
942
+ "name" => "Parse class sample objects",
955
943
  "description" => "Five most recent objects from a Parse class.",
956
- "mimeType" => "application/json",
944
+ "mimeType" => "application/json",
957
945
  },
958
946
  ],
959
947
  }
@@ -969,12 +957,12 @@ module Parse
969
957
  # @param agent [Parse::Agent]
970
958
  # @return [Hash] MCP contents envelope or an error hash.
971
959
  def self.handle_resources_read(params, agent)
972
- uri = params["uri"].to_s
960
+ uri = params["uri"].to_s
973
961
  match = uri.match(%r{\Aparse://([A-Za-z_][A-Za-z0-9_]*)(?:/(schema|count|samples))?\z})
974
962
  return { error: { "code" => -32602, "message" => "Invalid resource URI: #{uri}" } } unless match
975
963
 
976
964
  class_name = match[1]
977
- kind = match[2] || "schema"
965
+ kind = match[2] || "schema"
978
966
 
979
967
  result = case kind
980
968
  when "schema"
@@ -989,9 +977,9 @@ module Parse
989
977
  {
990
978
  "contents" => [
991
979
  {
992
- "uri" => uri,
980
+ "uri" => uri,
993
981
  "mimeType" => "application/json",
994
- "text" => JSON.pretty_generate(result[:data]),
982
+ "text" => JSON.pretty_generate(result[:data]),
995
983
  },
996
984
  ],
997
985
  }
@@ -1021,8 +1009,8 @@ module Parse
1021
1009
  return subscriptions_unsupported_error unless manager.respond_to?(:supported?) && manager.supported?
1022
1010
  ok = manager.subscribe(
1023
1011
  session_id: agent_session_id(agent),
1024
- uri: params["uri"].to_s,
1025
- agent: agent,
1012
+ uri: params["uri"].to_s,
1013
+ agent: agent,
1026
1014
  )
1027
1015
  return {} if ok
1028
1016
  # subscribe returned false: the session's listening stream was torn down
@@ -1032,8 +1020,8 @@ module Parse
1032
1020
  # contract an empty result == subscribed) so the client reopens its GET
1033
1021
  # stream and retries instead of waiting forever for updates.
1034
1022
  { error: { "code" => -32602,
1035
- "message" => "resources/subscribe: the session no longer has an open " \
1036
- "listening stream; reopen the GET stream and retry" } }
1023
+ "message" => "resources/subscribe: the session no longer has an open " \
1024
+ "listening stream; reopen the GET stream and retry" } }
1037
1025
  end
1038
1026
  private_class_method :handle_resources_subscribe
1039
1027
 
@@ -1047,7 +1035,7 @@ module Parse
1047
1035
  return subscriptions_unsupported_error unless manager.respond_to?(:supported?) && manager.supported?
1048
1036
  manager.unsubscribe(
1049
1037
  session_id: agent_session_id(agent),
1050
- uri: params["uri"].to_s,
1038
+ uri: params["uri"].to_s,
1051
1039
  )
1052
1040
  {}
1053
1041
  end
@@ -1100,8 +1088,8 @@ module Parse
1100
1088
  #
1101
1089
  # @return [Hash] MCP messages envelope or an error hash with :error key.
1102
1090
  def self.handle_prompts_get(params)
1103
- name = params["name"].to_s
1104
- args = params["arguments"] || {}
1091
+ name = params["name"].to_s
1092
+ args = params["arguments"] || {}
1105
1093
  result = Parse::Agent::Prompts.render(name, args)
1106
1094
 
1107
1095
  # Guard against oversized prompt renderers. The renderer is untrusted