parse-stack-next 5.7.4 → 5.7.5

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.
@@ -4,6 +4,7 @@
4
4
  require "json"
5
5
  require_relative "errors"
6
6
  require_relative "prompts"
7
+ require_relative "log_levels"
7
8
 
8
9
  module Parse
9
10
  class Agent
@@ -43,7 +44,14 @@ module Parse
43
44
  # still interpret the `initialize` capability shape and supported
44
45
  # methods correctly; the wire-level differences only matter for the
45
46
  # additive fields above.
46
- PROTOCOL_VERSION = "2025-06-18"
47
+ #
48
+ # Bumped to 2025-11-25 in 5.7.5. The changes that touch this server
49
+ # are additive: `serverInfo` may carry `description`, input
50
+ # validation failures are reported as tool results with
51
+ # `isError: true` rather than protocol errors, and elicitation gains
52
+ # URL mode and titled enums (the approval prompt uses neither). Tasks,
53
+ # icons, and tool-calling sampling are optional and not offered.
54
+ PROTOCOL_VERSION = "2025-11-25"
47
55
 
48
56
  # Protocol versions the dispatcher is willing to negotiate. Per the
49
57
  # MCP lifecycle spec the server MUST echo the client's requested
@@ -52,7 +60,7 @@ module Parse
52
60
  # method set are compatible with the handlers below — additions
53
61
  # from 2024-11-05 → 2025-06-18 are all additive and forward-
54
62
  # compatible no-ops for older clients.
55
- SUPPORTED_PROTOCOL_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
63
+ SUPPORTED_PROTOCOL_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
56
64
 
57
65
  # Server capability advertisement (mirrors MCPServer::CAPABILITIES).
58
66
  #
@@ -67,12 +75,73 @@ module Parse
67
75
  # callers (WEBrick, no streaming) cannot receive notifications;
68
76
  # they still see the latest registry state on the next
69
77
  # `tools/list` / `prompts/list` poll.
78
+ #
79
+ # `completions` backs `completion/complete` (class names and field
80
+ # names for prompt arguments and resource-template variables).
81
+ # `logging` backs `logging/setLevel`. It is advertised only when the
82
+ # transport can deliver log messages (see {capabilities_for}), and
83
+ # messages flow only after a client sets a level.
70
84
  CAPABILITIES = {
71
85
  "tools" => { "listChanged" => true },
72
86
  "resources" => { "subscribe" => false, "listChanged" => false },
73
87
  "prompts" => { "listChanged" => true },
88
+ "completions" => {},
89
+ "logging" => {},
74
90
  }.freeze
75
91
 
92
+ # RFC 5424 severities accepted by `logging/setLevel`, least to most
93
+ # severe. A session at a given level receives that level and above.
94
+ LOG_LEVELS = Parse::Agent::LOG_LEVELS
95
+
96
+ # Prompt-argument and resource-template-variable names completed
97
+ # with the class names visible to the agent. `classes` is a
98
+ # comma-separated list, so only its last segment is completed.
99
+ CLASS_COMPLETION_ARGUMENTS = %w[class_name parent_class child_class classes className].freeze
100
+
101
+ # Argument names completed with field names of the class named by a
102
+ # sibling argument (`class_name` or `child_class`) in the request's
103
+ # `context.arguments`.
104
+ FIELD_COMPLETION_ARGUMENTS = %w[group_by pointer_field].freeze
105
+
106
+ # The MCP spec caps a completion response at 100 values.
107
+ MAX_COMPLETION_VALUES = 100
108
+
109
+ # Per-session minimum log level, keyed by the MCP session id
110
+ # (the agent's correlation id). LRU-bounded so a stream of sessions
111
+ # that never terminate cannot grow it without limit.
112
+ class LogLevelRegistry
113
+ DEFAULT_MAX_ENTRIES = 10_000
114
+
115
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES)
116
+ @levels = {}
117
+ @max = max_entries
118
+ @mutex = Mutex.new
119
+ end
120
+
121
+ # @param session_id [String]
122
+ # @param level [String] one of {LOG_LEVELS}.
123
+ def set(session_id, level)
124
+ return if session_id.nil? || session_id.to_s.empty?
125
+ @mutex.synchronize do
126
+ @levels.delete(session_id.to_s)
127
+ @levels[session_id.to_s] = level
128
+ @levels.shift while @levels.size > @max
129
+ end
130
+ end
131
+
132
+ # @return [String, nil] the level the session asked for, or nil
133
+ # when it never sent `logging/setLevel`.
134
+ def get(session_id)
135
+ return nil if session_id.nil?
136
+ @mutex.synchronize { @levels[session_id.to_s] }
137
+ end
138
+
139
+ def forget(session_id)
140
+ return if session_id.nil?
141
+ @mutex.synchronize { @levels.delete(session_id.to_s) }
142
+ end
143
+ end
144
+
76
145
  # Parse class-name identifier regex — used to validate resource URIs.
77
146
  # Matches Parse's class-name convention: letter/underscore start, up to 128
78
147
  # chars, alphanumeric/underscore body.
@@ -140,8 +209,16 @@ module Parse
140
209
  # nil (the default, and the only option on non-streaming transports like
141
210
  # the WEBrick MCPServer) leaves the capability unadvertised and those
142
211
  # methods returning a "not supported" error.
212
+ # @param log_callback [#call, nil] installed on the agent for the
213
+ # duration of the request so tools (and the dispatcher) can emit
214
+ # MCP `notifications/message` events via `agent.log(...)`. Set by
215
+ # Parse::Agent::MCPRackApp on the SSE path; the transport filters by
216
+ # the session's level. nil leaves `agent.log` a no-op.
217
+ # @param log_levels [LogLevelRegistry, nil] where `logging/setLevel`
218
+ # records the session's level. nil (non-streaming transports, which
219
+ # cannot deliver log messages) still accepts and validates the call.
143
220
  def self.call(body:, agent:, logger: nil, progress_callback: nil, cancellation_token: nil,
144
- subscription_manager: nil, approval_gate: nil)
221
+ subscription_manager: nil, approval_gate: nil, log_callback: nil, log_levels: nil)
145
222
  # Snapshot any prior callback/token already on the agent (e.g. a
146
223
  # token a parent dispatcher installed before a tool handler
147
224
  # invoked us recursively, or values pre-set by the application).
@@ -153,6 +230,7 @@ module Parse
153
230
  prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
154
231
  prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
155
232
  prev_approval_gate = agent.approval_gate if agent.respond_to?(:approval_gate)
233
+ prev_log_callback = agent.log_callback if agent.respond_to?(:log_callback)
156
234
 
157
235
  # Install the progress callback and cancellation token on the
158
236
  # agent for the duration of the dispatch. Cleared in the ensure
@@ -170,6 +248,7 @@ module Parse
170
248
  # agent.execute can request human approval for destructive tools.
171
249
  # Restored in the ensure block like the other per-request state.
172
250
  agent.approval_gate = approval_gate if approval_gate && agent.respond_to?(:approval_gate=)
251
+ agent.log_callback = log_callback if log_callback && agent.respond_to?(:log_callback=)
173
252
 
174
253
  # Guard: body must be a Hash with a "method" key.
175
254
  unless body.is_a?(Hash) && body.key?("method")
@@ -189,7 +268,7 @@ module Parse
189
268
  return { status: 200, body: jsonrpc_error(id, -32600, "Invalid Request: notifications must not carry an id") }
190
269
  end
191
270
 
192
- result_hash = dispatch(method, params, agent, id, logger, subscription_manager)
271
+ result_hash = dispatch(method, params, agent, id, logger, subscription_manager, log_levels)
193
272
  { status: result_hash[:status], body: result_hash[:body] }
194
273
  rescue Parse::Agent::Unauthorized
195
274
  { status: 401, body: jsonrpc_error(body.is_a?(Hash) ? body["id"] : nil, -32001, "Unauthorized") }
@@ -213,6 +292,9 @@ module Parse
213
292
  if agent.respond_to?(:approval_gate=)
214
293
  agent.approval_gate = prev_approval_gate
215
294
  end
295
+ if agent.respond_to?(:log_callback=)
296
+ agent.log_callback = prev_log_callback
297
+ end
216
298
  end
217
299
 
218
300
  # Emit an internal-error diagnostic. The class+message are operator-only;
@@ -235,10 +317,10 @@ module Parse
235
317
  # envelope, and return { status:, body: }.
236
318
  #
237
319
  # @api private
238
- def self.dispatch(method, params, agent, id, logger = nil, subscription_manager = nil)
320
+ def self.dispatch(method, params, agent, id, logger = nil, subscription_manager = nil, log_levels = nil)
239
321
  result = case method
240
322
  when "initialize"
241
- handle_initialize(params, subscription_manager)
323
+ handle_initialize(params, subscription_manager, log_levels)
242
324
  when "tools/list"
243
325
  handle_tools_list(params, agent)
244
326
  when "tools/call"
@@ -257,6 +339,10 @@ module Parse
257
339
  handle_prompts_list(params)
258
340
  when "prompts/get"
259
341
  handle_prompts_get(params)
342
+ when "completion/complete"
343
+ handle_completion_complete(params, agent)
344
+ when "logging/setLevel"
345
+ handle_logging_set_level(params, agent, log_levels)
260
346
  when "ping"
261
347
  {}
262
348
  when "notifications/cancelled"
@@ -336,7 +422,7 @@ module Parse
336
422
  # when supported, flips the advertised `resources.subscribe` capability
337
423
  # to true. See {#capabilities_for}.
338
424
  # @return [Hash] protocol version, capabilities, and server info.
339
- def self.handle_initialize(params, subscription_manager = nil)
425
+ def self.handle_initialize(params, subscription_manager = nil, log_levels = nil)
340
426
  requested = params.is_a?(Hash) ? params["protocolVersion"] : nil
341
427
  negotiated = if requested.is_a?(String) && SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
342
428
  requested
@@ -345,10 +431,13 @@ module Parse
345
431
  end
346
432
  {
347
433
  "protocolVersion" => negotiated,
348
- "capabilities" => capabilities_for(subscription_manager),
434
+ "capabilities" => capabilities_for(subscription_manager, logging: !log_levels.nil?),
349
435
  "serverInfo" => {
350
436
  "name" => "parse-stack-mcp",
437
+ "title" => "Parse Stack MCP",
351
438
  "version" => Parse::Stack::VERSION,
439
+ "description" => "Schema introspection, queries, and aggregations over a Parse Server " \
440
+ "application, scoped to the connecting agent's permissions.",
352
441
  },
353
442
  }
354
443
  end
@@ -367,11 +456,17 @@ module Parse
367
456
  #
368
457
  # @param manager [Parse::Agent::MCPSubscriptions::Manager, nil]
369
458
  # @return [Hash]
370
- def self.capabilities_for(manager)
371
- return CAPABILITIES unless manager.respond_to?(:supported?) && manager.supported?
372
- CAPABILITIES.merge(
373
- "resources" => CAPABILITIES["resources"].merge("subscribe" => true),
374
- )
459
+ # `logging` is dropped when the transport cannot deliver
460
+ # `notifications/message` (the WEBrick MCPServer, or MCPRackApp with
461
+ # streaming off), so a client is never told to expect log messages
462
+ # that cannot arrive.
463
+ def self.capabilities_for(manager, logging: true)
464
+ caps = CAPABILITIES
465
+ if manager.respond_to?(:supported?) && manager.supported?
466
+ caps = caps.merge("resources" => caps["resources"].merge("subscribe" => true))
467
+ end
468
+ caps = caps.reject { |k, _| k == "logging" } unless logging
469
+ caps
375
470
  end
376
471
  private_class_method :capabilities_for
377
472
 
@@ -411,6 +506,17 @@ module Parse
411
506
  return { error: { "code" => -32602, "message" => "Missing tool name" } }
412
507
  end
413
508
 
509
+ # Input validation failures are tool results, not protocol errors
510
+ # (MCP 2025-11-25), so the model can see the problem and correct
511
+ # its call. agent.execute already reports bad argument values this
512
+ # way; a non-object `arguments` never reaches it.
513
+ unless arguments.is_a?(Hash)
514
+ return {
515
+ "content" => [{ "type" => "text", "text" => "Invalid arguments: `arguments` must be a JSON object." }],
516
+ "isError" => true,
517
+ }
518
+ end
519
+
414
520
  sym_args = arguments.transform_keys(&:to_sym)
415
521
  result = agent.execute(tool_name.to_sym, **sym_args)
416
522
 
@@ -502,11 +608,142 @@ module Parse
502
608
  meta["parse.retry_after"] = result[:retry_after] if result[:retry_after]
503
609
  meta["parse.details"] = result[:details] if result[:details].is_a?(Hash) && result[:details].any?
504
610
  envelope["_meta"] = meta unless meta.empty?
611
+ if agent.respond_to?(:log)
612
+ agent.log(:warning, { "tool" => tool_name.to_s, "error_code" => result[:error_code]&.to_s },
613
+ logger: "parse.agent.tools")
614
+ end
505
615
  envelope
506
616
  end
507
617
  end
508
618
  private_class_method :handle_tools_call
509
619
 
620
+ # Handle `completion/complete`.
621
+ #
622
+ # Completes class names for any prompt argument named in
623
+ # {CLASS_COMPLETION_ARGUMENTS} and for the `{className}` variable of
624
+ # this server's resource templates, and field names for arguments in
625
+ # {FIELD_COMPLETION_ARGUMENTS}. Candidates come from the same agent
626
+ # tools that back `resources/list` and `get_schema`, so a class or
627
+ # field the agent may not see is never offered. Any other argument
628
+ # completes to an empty list.
629
+ #
630
+ # @return [Hash] `{ "completion" => { "values", "total", "hasMore" } }`
631
+ # or an error hash for a malformed or unknown reference.
632
+ #
633
+ # Each request runs the schema tools through `agent.execute`, so it is
634
+ # subject to the agent's tool allowlist and audit trail and counts
635
+ # against its rate limiter like any tool call. Clients that complete
636
+ # on every keystroke should debounce.
637
+ def self.handle_completion_complete(params, agent)
638
+ return { error: { "code" => -32602, "message" => "Invalid params" } } unless params.is_a?(Hash)
639
+ ref = params["ref"]
640
+ argument = params["argument"]
641
+ unless ref.is_a?(Hash) && argument.is_a?(Hash) && argument["name"].is_a?(String)
642
+ return { error: { "code" => -32602, "message" => "completion/complete requires ref and argument.name" } }
643
+ end
644
+ unless argument["value"].nil? || argument["value"].is_a?(String)
645
+ return { error: { "code" => -32602, "message" => "argument.value must be a string" } }
646
+ end
647
+ context = params["context"]
648
+ unless context.nil? || (context.is_a?(Hash) && (context["arguments"].nil? || context["arguments"].is_a?(Hash)))
649
+ return { error: { "code" => -32602, "message" => "context.arguments must be an object" } }
650
+ end
651
+ name = argument["name"]
652
+ value = argument["value"].to_s
653
+
654
+ case ref["type"]
655
+ when "ref/prompt"
656
+ prompt = Parse::Agent::Prompts.list.find { |p| p["name"] == ref["name"] }
657
+ return { error: { "code" => -32602, "message" => "Unknown prompt: #{ref["name"]}" } } unless prompt
658
+ unless Array(prompt["arguments"]).any? { |a| a["name"] == name }
659
+ return { error: { "code" => -32602, "message" => "Unknown argument: #{name}" } }
660
+ end
661
+ when "ref/resource"
662
+ templates = handle_resources_templates_list(nil, agent)["resourceTemplates"].map { |t| t["uriTemplate"] }
663
+ unless templates.include?(ref["uri"])
664
+ return { error: { "code" => -32602, "message" => "Unknown resource template: #{ref["uri"]}" } }
665
+ end
666
+ else
667
+ return { error: { "code" => -32602, "message" => "Unsupported ref type: #{ref["type"]}" } }
668
+ end
669
+
670
+ context_args = (context && context["arguments"]) || {}
671
+ values = if CLASS_COMPLETION_ARGUMENTS.include?(name)
672
+ complete_class_names(agent, value, list: name == "classes")
673
+ elsif FIELD_COMPLETION_ARGUMENTS.include?(name)
674
+ complete_field_names(agent, context_args["class_name"] || context_args["child_class"], value)
675
+ else
676
+ []
677
+ end
678
+
679
+ {
680
+ "completion" => {
681
+ "values" => values.first(MAX_COMPLETION_VALUES),
682
+ "total" => values.size,
683
+ "hasMore" => values.size > MAX_COMPLETION_VALUES,
684
+ },
685
+ }
686
+ end
687
+ private_class_method :handle_completion_complete
688
+
689
+ # Class names visible to the agent that start with `value`
690
+ # (case-insensitive). With `list: true`, `value` is a comma-separated
691
+ # list and only its last segment is matched; each candidate is
692
+ # returned with the earlier segments prefixed so the client can
693
+ # replace the whole value.
694
+ def self.complete_class_names(agent, value, list: false)
695
+ head, sep, partial = list ? value.rpartition(",") : ["", "", value]
696
+ # Keep any spacing the caller typed after the comma.
697
+ spacing = partial[/\A\s*/]
698
+ partial = partial.lstrip
699
+ prefix = "#{head}#{sep}#{spacing}"
700
+ result = agent.execute(:get_all_schemas)
701
+ return [] unless result[:success]
702
+ data = result[:data] || {}
703
+ # Same envelope handling as {handle_resources_list}, including the
704
+ # legacy `classes` key.
705
+ classes = (data[:custom] || []) + (data[:built_in] || [])
706
+ classes = data[:classes] || [] if classes.empty? && data[:classes]
707
+ names = classes.map { |c| c[:name].to_s }
708
+ names.select { |n| n.downcase.start_with?(partial.downcase) }.sort.map { |n| "#{prefix}#{n}" }
709
+ end
710
+ private_class_method :complete_class_names
711
+
712
+ # Field names of `class_name` (as the agent sees them) that start
713
+ # with `value`. Empty when the class is missing, invalid, or hidden.
714
+ def self.complete_field_names(agent, class_name, value)
715
+ return [] unless class_name.is_a?(String) && class_name.match?(IDENTIFIER_RE)
716
+ result = agent.execute(:get_schema, class_name: class_name)
717
+ return [] unless result[:success]
718
+ fields = (result[:data] || {})[:fields]
719
+ return [] unless fields.is_a?(Array)
720
+ fields.map { |f| f[:name].to_s }.select { |n| n.downcase.start_with?(value.downcase) }.sort
721
+ end
722
+ private_class_method :complete_field_names
723
+
724
+ # Handle `logging/setLevel`. Records the session's minimum level in
725
+ # `log_levels` (when the transport supplies one) so the streaming
726
+ # transport sends that level and above as `notifications/message`.
727
+ # Until a client sets a level, no log messages are sent.
728
+ #
729
+ # The call is a silent no-op (still `{}`) when there is nowhere to
730
+ # record the level: no `log_levels` (the transport cannot deliver
731
+ # logs, or the request's session is not owned by the caller), or no
732
+ # session id on the agent.
733
+ #
734
+ # @return [Hash] `{}` or an error hash for an unknown level.
735
+ def self.handle_logging_set_level(params, agent, log_levels)
736
+ return { error: { "code" => -32602, "message" => "Invalid params" } } unless params.is_a?(Hash)
737
+ level = params["level"]
738
+ unless level.is_a?(String) && LOG_LEVELS.include?(level)
739
+ return { error: { "code" => -32602, "message" => "Invalid log level: #{level.inspect}. Expected one of #{LOG_LEVELS.join(", ")}." } }
740
+ end
741
+ session_id = agent.respond_to?(:correlation_id) ? agent.correlation_id : nil
742
+ log_levels&.set(session_id, level)
743
+ {}
744
+ end
745
+ private_class_method :handle_logging_set_level
746
+
510
747
  # Sample the tool's result envelope and produce a one-line diagnostic
511
748
  # naming the fields that contribute the most bytes per record. Returns
512
749
  # nil if the data shape isn't amenable to per-field analysis. Called
@@ -405,6 +405,10 @@ module Parse
405
405
  # and always present; they only do work when
406
406
  # Parse::Agent.require_approval_for opts a tier in.
407
407
  @elicitation_capabilities = Parse::Agent::ClientCapabilityRegistry.new
408
+ # Per-session minimum log level set by `logging/setLevel`. Log
409
+ # messages ride the response stream of an SSE request, so a session
410
+ # that never sets a level (or never streams) receives none.
411
+ @log_levels = Parse::Agent::MCPDispatcher::LogLevelRegistry.new
408
412
  @pending_elicitations = Parse::Agent::PendingElicitationRegistry.new
409
413
  @approval_timeout = approval_timeout
410
414
 
@@ -627,6 +631,7 @@ module Parse
627
631
  # session's cached elicitation capability.
628
632
  @pending_elicitations.abort_all_for(clean_sid, :session_terminated)
629
633
  @elicitation_capabilities.forget(clean_sid)
634
+ @log_levels.forget(clean_sid)
630
635
  # Tear down any resource subscriptions and the listening stream
631
636
  # bound to this session so a terminated session leaves no LiveQuery
632
637
  # sockets behind.
@@ -733,7 +738,13 @@ module Parse
733
738
  # may be sent by a client that has not (yet) completed
734
739
  # initialize against this transport instance (e.g. a
735
740
  # reconnecting client cancelling a pre-disconnect request).
741
+ # `server/discover` is exempt too. Newer clients send it before
742
+ # initialize, stamped with their own (newer) protocol version.
743
+ # A 400 here makes the client treat the server as broken. Letting
744
+ # it through yields -32601 from the dispatcher, and the client
745
+ # falls back to initialize, which negotiates a supported version.
736
746
  unless body["method"] == "initialize" ||
747
+ body["method"] == "server/discover" ||
737
748
  body["method"] == "notifications/cancelled" ||
738
749
  elicitation_reply?(body)
739
750
  requested = env["HTTP_MCP_PROTOCOL_VERSION"]
@@ -808,12 +819,18 @@ module Parse
808
819
  # per session before attempting a server→client prompt.
809
820
  if body.is_a?(Hash) && body["method"] == "initialize" &&
810
821
  agent.respond_to?(:correlation_id) && agent.correlation_id
811
- supported = !!(body.dig("params", "capabilities", "elicitation"))
822
+ # Bind this session to the initializing principal FIRST, so only the
823
+ # same principal can later attach a listening stream, set its log
824
+ # level, or have its elicitation capability recorded (owner-binding;
825
+ # see SessionOwnerRegistry). A session id already owned by another
826
+ # principal is refused outright rather than rebound.
827
+ unless @session_owners.bind(agent.correlation_id, principal_fingerprint(agent, env))
828
+ @logger&.warn("[Parse::Agent::MCPRackApp] initialize refused: session owned by another principal")
829
+ return [403, json_headers,
830
+ [json_rpc_error(-32_600, "Mcp-Session-Id is owned by another principal", id: body["id"])]]
831
+ end
832
+ supported = elicitation_form_supported?(body.dig("params", "capabilities", "elicitation"))
812
833
  @elicitation_capabilities.set(agent.correlation_id, supported)
813
- # Authoritatively bind this session to the initializing principal so
814
- # only the same principal can later attach a listening stream for it
815
- # (owner-binding; see SessionOwnerRegistry).
816
- @session_owners.bind(agent.correlation_id, principal_fingerprint(agent, env))
817
834
  end
818
835
 
819
836
  # 5b-iii. Elicitation reply ingress. A method-less JSON-RPC
@@ -854,10 +871,11 @@ module Parse
854
871
 
855
872
  # 6. Branch on streaming preference. Transport-level errors (steps 1-5)
856
873
  # always return plain JSON regardless of the Accept header.
874
+ log_levels = session_log_levels(agent, env)
857
875
  if @streaming && env["HTTP_ACCEPT"].to_s.include?("text/event-stream")
858
- serve_sse(body, agent)
876
+ serve_sse(body, agent, log_levels)
859
877
  else
860
- serve_json(body, agent)
878
+ serve_json(body, agent, log_levels)
861
879
  end
862
880
  end
863
881
 
@@ -872,6 +890,17 @@ module Parse
872
890
  # @param body [Hash] parsed JSON-RPC request body.
873
891
  # @param agent [Parse::Agent] authenticated agent.
874
892
  # @return [Array] Rack triple with Array<String> body.
893
+ # Whether a client's `capabilities.elicitation` admits form-mode
894
+ # requests, which is what the approval prompt sends. Since 2025-11-25
895
+ # a client declares its modes (`{ form: {} }`, `{ url: {} }`, or
896
+ # both); an empty object is the pre-2025-11-25 shape and means form.
897
+ # A URL-only client must not be sent a form, or the approval is
898
+ # refused as if the user had cancelled it.
899
+ def elicitation_form_supported?(capability)
900
+ return false unless capability.is_a?(Hash)
901
+ capability.empty? || capability.key?("form")
902
+ end
903
+
875
904
  # True when `body` is a JSON-RPC RESPONSE (no "method"; carries an
876
905
  # "id" plus "result" or "error") — the client's reply to a
877
906
  # server-issued elicitation/create request.
@@ -930,11 +959,26 @@ module Parse
930
959
  )
931
960
  end
932
961
 
933
- def serve_json(body, agent)
962
+ # The log-level registry this request may read and write, or nil.
963
+ #
964
+ # nil when the app does not stream (log messages could never be
965
+ # delivered) or when the request's session id is not bound to this
966
+ # request's principal. The owner check keeps one caller from setting
967
+ # another session's level, and because only `initialize` binds a
968
+ # session, a caller cannot fill the registry with invented ids.
969
+ def session_log_levels(agent, env)
970
+ return nil unless @streaming
971
+ cid = agent.respond_to?(:correlation_id) ? agent.correlation_id : nil
972
+ return nil unless @session_owners.owned_by?(cid, principal_fingerprint(agent, env))
973
+ @log_levels
974
+ end
975
+
976
+ def serve_json(body, agent, log_levels = nil)
934
977
  result = Parse::Agent::MCPDispatcher.call(
935
978
  body: body, agent: agent, logger: @logger,
936
979
  subscription_manager: @subscription_manager,
937
980
  approval_gate: build_approval_gate(agent),
981
+ log_levels: log_levels,
938
982
  )
939
983
  headers = json_headers
940
984
  merge_session_header!(headers, body, agent)
@@ -967,7 +1011,7 @@ module Parse
967
1011
  # @param body [Hash] parsed JSON-RPC request body.
968
1012
  # @param agent [Parse::Agent] authenticated agent.
969
1013
  # @return [Array] Rack triple with SSEBody or a 503 JSON error as the body.
970
- def serve_sse(body, agent)
1014
+ def serve_sse(body, agent, log_levels = nil)
971
1015
  # NOTE: this check is not mutex-protected, so two concurrent requests
972
1016
  # arriving within the same scheduling quantum can both pass the check
973
1017
  # and each spawn a dispatcher_thread, briefly exceeding the limit by
@@ -1010,7 +1054,8 @@ module Parse
1010
1054
  progress_token, req_id, interval, logger,
1011
1055
  cancellation_token: cancellation_token,
1012
1056
  on_close: -> { registry.deregister(correlation_id, req_id, registry_entry_id) if registry_entry_id },
1013
- ) do |progress_callback|
1057
+ log_level_lookup: log_levels && -> { log_levels.get(correlation_id) },
1058
+ ) do |progress_callback, log_callback|
1014
1059
  Parse::Agent::MCPDispatcher.call(
1015
1060
  body: body,
1016
1061
  agent: agent,
@@ -1019,6 +1064,8 @@ module Parse
1019
1064
  cancellation_token: cancellation_token,
1020
1065
  subscription_manager: @subscription_manager,
1021
1066
  approval_gate: build_approval_gate(agent),
1067
+ log_callback: log_callback,
1068
+ log_levels: log_levels,
1022
1069
  )
1023
1070
  end
1024
1071
 
@@ -1211,6 +1258,14 @@ module Parse
1211
1258
  # @return [Proc]
1212
1259
  attr_reader :progress_callback
1213
1260
 
1261
+ # Callback exposed to the dispatcher block as the agent's
1262
+ # `log_callback`. Pushes a `notifications/message` event when the
1263
+ # message's level is at or above the session's level, and drops it
1264
+ # otherwise. nil when no level lookup was supplied.
1265
+ #
1266
+ # @return [Proc, nil]
1267
+ attr_reader :log_callback
1268
+
1214
1269
  # @param progress_token [String] MCP progressToken value.
1215
1270
  # @param req_id [Object] JSON-RPC request id (may be nil).
1216
1271
  # @param interval [Numeric] heartbeat period in seconds.
@@ -1222,8 +1277,11 @@ module Parse
1222
1277
  # @param on_close [Proc, nil] callback invoked from {#close} after
1223
1278
  # the worker has been terminated. Used by MCPRackApp to
1224
1279
  # deregister the cancellation token from the per-app registry.
1225
- # @param dispatcher_blk [Proc] called with one argument (the
1226
- # {#progress_callback} Proc); must return the same
1280
+ # @param log_level_lookup [Proc, nil] returns the session's
1281
+ # minimum log level (a String) or nil when the client never set
1282
+ # one. nil disables {#log_callback} entirely.
1283
+ # @param dispatcher_blk [Proc] called with the {#progress_callback}
1284
+ # and {#log_callback} Procs; must return the same
1227
1285
  # `{ status:, body: }` hash that MCPDispatcher.call returns.
1228
1286
  # @param heartbeat_waiter [Proc, nil] test hook. Called as
1229
1287
  # `waiter.call(dispatcher_thread, interval)` once per heartbeat
@@ -1234,7 +1292,7 @@ module Parse
1234
1292
  # to OS scheduler jitter.
1235
1293
  def initialize(progress_token, req_id, interval, logger,
1236
1294
  cancellation_token: nil, on_close: nil,
1237
- heartbeat_waiter: nil, &dispatcher_blk)
1295
+ heartbeat_waiter: nil, log_level_lookup: nil, &dispatcher_blk)
1238
1296
  @progress_token = progress_token
1239
1297
  # Heartbeats use a dedicated server-generated progressToken so
1240
1298
  # the elapsed-seconds scale of heartbeats never appears on the
@@ -1271,6 +1329,7 @@ module Parse
1271
1329
  # actively reporting, time-based heartbeats are noise.
1272
1330
  @tool_progress_reported = false
1273
1331
  @progress_callback = build_progress_callback
1332
+ @log_callback = build_log_callback(log_level_lookup)
1274
1333
  # Deregistration callbacks for the Tools/Prompts subscribe
1275
1334
  # bindings. Set when the worker starts (so a request that is
1276
1335
  # never driven via #each does not register a stale entry) and
@@ -1494,7 +1553,7 @@ module Parse
1494
1553
  # tools running inside MCPDispatcher.call can emit
1495
1554
  # notifications/progress events without coupling to
1496
1555
  # SSEBody internals.
1497
- result = @dispatcher_blk.call(@progress_callback)
1556
+ result = @dispatcher_blk.call(@progress_callback, @log_callback)
1498
1557
  rescue StandardError => e
1499
1558
  # Log the unexpected failure (MCPDispatcher.call normally catches
1500
1559
  # StandardError internally; anything reaching here is unusual).
@@ -1643,6 +1702,33 @@ module Parse
1643
1702
  end
1644
1703
  end
1645
1704
 
1705
+ # Build the callback behind {#log_callback}. The session's level is
1706
+ # read on every message, so a `logging/setLevel` sent mid-request
1707
+ # takes effect for later messages. Encoder or queue failures are
1708
+ # logged and swallowed, like {#build_progress_callback}.
1709
+ def build_log_callback(lookup)
1710
+ return nil if lookup.nil?
1711
+ diag = @logger
1712
+ levels = Parse::Agent::MCPDispatcher::LOG_LEVELS
1713
+ lambda do |level:, data:, logger: nil|
1714
+ begin
1715
+ min = lookup.call
1716
+ rank = levels.index(level.to_s)
1717
+ min_rank = min && levels.index(min)
1718
+ if rank && min_rank && rank >= min_rank
1719
+ params = { "level" => level.to_s, "data" => data }
1720
+ params["logger"] = logger.to_s if logger
1721
+ payload = JSON.generate({ "jsonrpc" => "2.0", "method" => "notifications/message", "params" => params })
1722
+ @queue << "event: message\ndata: #{payload}\n\n"
1723
+ end
1724
+ rescue StandardError => e
1725
+ line = "[Parse::Agent::MCPRackApp::SSEBody] log_callback error: #{e.class}: #{e.message}"
1726
+ diag ? diag.warn(line) : warn(line)
1727
+ end
1728
+ nil
1729
+ end
1730
+ end
1731
+
1646
1732
  # Format the final JSON-RPC response SSE event.
1647
1733
  #
1648
1734
  # Emitted as `event: message` (NOT `event: response`) — an MCP
@@ -1851,14 +1937,24 @@ module Parse
1851
1937
  @mutex = Mutex.new
1852
1938
  end
1853
1939
 
1854
- # Authoritatively bind a session to a principal (initialize). A
1855
- # re-initialize by the same caller refreshes the binding.
1940
+ # Bind a session to a principal at initialize. An unclaimed session
1941
+ # is claimed; a re-initialize by the owning principal refreshes the
1942
+ # binding. A session already owned by a different principal is NOT
1943
+ # rebound, so knowing another caller's session id is not enough to
1944
+ # take it over (and with it, its log level, elicitation capability,
1945
+ # and listening stream).
1946
+ #
1947
+ # @return [Boolean] true when bound to `fingerprint`; false on a
1948
+ # principal mismatch or blank input.
1856
1949
  def bind(session_id, fingerprint)
1857
- return if blank?(session_id) || blank?(fingerprint)
1950
+ return false if blank?(session_id) || blank?(fingerprint)
1858
1951
  @mutex.synchronize do
1952
+ owner = @owners[session_id]
1953
+ return false if owner && owner != fingerprint
1859
1954
  @owners.delete(session_id)
1860
1955
  @owners[session_id] = fingerprint
1861
1956
  evict_lru!
1957
+ true
1862
1958
  end
1863
1959
  end
1864
1960
 
@@ -1884,6 +1980,13 @@ module Parse
1884
1980
  end
1885
1981
  end
1886
1982
 
1983
+ # True when `session_id` is bound to exactly this principal. Never
1984
+ # claims an unbound session (unlike {#authorize_attach}).
1985
+ def owned_by?(session_id, fingerprint)
1986
+ return false if blank?(session_id) || blank?(fingerprint)
1987
+ @mutex.synchronize { @owners[session_id] == fingerprint }
1988
+ end
1989
+
1887
1990
  # Drop a session's owner binding (explicit DELETE termination). Not
1888
1991
  # called on mere stream close, so a reconnecting owner keeps its claim
1889
1992
  # and an attacker can't grab the id during a brief disconnect.