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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +164 -0
- data/README.md +3 -0
- data/docs/atlas_vector_search_guide.md +125 -10
- data/docs/mcp_guide.md +81 -0
- data/lib/parse/agent/log_levels.rb +11 -0
- data/lib/parse/agent/mcp_dispatcher.rb +250 -13
- data/lib/parse/agent/mcp_rack_app.rb +120 -17
- data/lib/parse/agent.rb +40 -0
- data/lib/parse/atlas_search.rb +5 -1
- data/lib/parse/embeddings/cache.rb +17 -5
- data/lib/parse/embeddings/provider.rb +26 -0
- data/lib/parse/embeddings/voyage.rb +214 -15
- data/lib/parse/embeddings.rb +3 -1
- data/lib/parse/model/core/embed_managed.rb +8 -2
- data/lib/parse/mongodb.rb +49 -2
- data/lib/parse/query.rb +5 -5
- data/lib/parse/retrieval/reranker/voyage.rb +282 -0
- data/lib/parse/retrieval/reranker.rb +2 -0
- data/lib/parse/stack/version.rb +1 -1
- data/parse-stack-next.gemspec +1 -0
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1226
|
-
#
|
|
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
|
-
#
|
|
1855
|
-
# re-initialize by the
|
|
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.
|