ask-mcp 0.4.6 → 0.6.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.
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+
6
+ module Ask
7
+ module MCP
8
+ class Server
9
+ # Transport-agnostic MCP request processing.
10
+ #
11
+ # A Core owns one server's tools, resources and prompts and turns a
12
+ # single parsed JSON-RPC message into the messages the server wants to
13
+ # send back. Transports differ only in how those messages reach the
14
+ # client: Stdio writes each one to $stdout as it is produced, the HTTP
15
+ # transport collects them in #outbox and renders a response.
16
+ #
17
+ # Negotiated protocol state (`@protocol_version`) lives on the instance,
18
+ # so a stateless transport builds a Core per request rather than sharing
19
+ # one across callers.
20
+ class Core
21
+ MAX_RESULT_CACHE = 100
22
+
23
+ attr_reader :name, :tools, :capabilities, :resources, :prompts,
24
+ :resource_templates, :outbox
25
+
26
+ # The protocol version a request declares in params `_meta`, or nil for
27
+ # a legacy request that expects an `initialize` handshake. Handles both
28
+ # symbol and string key forms (the JSON parser symbolizes all keys).
29
+ def self.declared_protocol_version(params)
30
+ meta = params[:meta] || params[:_meta] || {}
31
+ key = Native::Messages::Meta::PROTOCOL_VERSION_KEY
32
+ meta[key] || meta[key.to_sym] || meta[key.to_s]
33
+ end
34
+
35
+ def initialize(name:, version: nil, tools: [], capabilities: { tools: {} },
36
+ resources: {}, prompts: {}, resource_templates: {},
37
+ debug: false, tool_timeout: nil, cache_ttl_ms: 60_000,
38
+ cache_scope: "private")
39
+ @name = name
40
+ @server_version = version || Ask::MCP::VERSION
41
+ @tools = tools || []
42
+ @capabilities = capabilities
43
+ @resources = resources
44
+ @prompts = prompts
45
+ @resource_templates = resource_templates
46
+ @debug = debug
47
+ @tool_timeout = tool_timeout
48
+ @cache_ttl_ms = cache_ttl_ms
49
+ @cache_scope = cache_scope
50
+
51
+ @adapter = Adapters::ToolServer.new(@tools)
52
+ @outbox = []
53
+ @initialized = false
54
+ @result_cache = {}
55
+ # Negotiated protocol version. nil until the client tells us which
56
+ # revision it speaks (legacy `initialize` or stateless `_meta`).
57
+ @protocol_version = nil
58
+ end
59
+
60
+ # Methods held behind the legacy gate. A stateless (2026-07-28) request
61
+ # opens them by declaring its version; a legacy client opens them with
62
+ # `initialize`.
63
+ GATED_METHODS = %w[
64
+ tools/list tools/call
65
+ resources/list resources/read resources/templates/list
66
+ prompts/list prompts/get
67
+ ].freeze
68
+
69
+ # Process one JSON-RPC message (symbolized keys). Every message the
70
+ # server produces is handed to #deliver, in order.
71
+ def handle_message(msg)
72
+ method = msg[:method]
73
+ id = msg[:id]
74
+ params = msg[:params] || {}
75
+ has_id = msg.key?(:id)
76
+
77
+ # A stateless (2026-07-28) request carries its protocol version in
78
+ # `_meta` instead of an `initialize` handshake; a version this server
79
+ # does not speak is answered here and the message stops.
80
+ version = meta_protocol_version(params)
81
+ if version && !supported_version?(version)
82
+ return send_error(id, Native::Messages::ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
83
+ "Unsupported protocol version: #{version}")
84
+ end
85
+
86
+ adopt_version(version)
87
+ return send_error(id, -32_000, "Server not initialized") if gated?(method) && !@initialized
88
+
89
+ case method
90
+ when "initialize"
91
+ handle_initialize(id, params)
92
+ when "server/discover"
93
+ handle_discover(id)
94
+ when "notifications/initialized"
95
+ @initialized = true
96
+ debug_log "Client initialized"
97
+ when "tools/list"
98
+ handle_tools_list(id)
99
+ when "tools/call"
100
+ handle_tool_call(id, params)
101
+ when "resources/list"
102
+ handle_resources_list(id)
103
+ when "resources/read"
104
+ handle_resource_read(id, params)
105
+ when "resources/templates/list"
106
+ handle_resources_templates_list(id)
107
+ when "prompts/list"
108
+ handle_prompts_list(id)
109
+ when "prompts/get"
110
+ handle_prompt_get(id, params)
111
+ when "ping"
112
+ # ping was removed in 2026-07-28; legacy clients still use it.
113
+ if stateless_mode?
114
+ send_error(id, Native::Messages::ErrorCodes::METHOD_NOT_FOUND, "Method not found: ping") if has_id
115
+ elsif has_id
116
+ send_result(id, {})
117
+ end
118
+ else
119
+ debug_log "Unknown method: #{method}"
120
+ send_error(id, Native::Messages::ErrorCodes::METHOD_NOT_FOUND, "Method not found: #{method}") if has_id
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ # Hand an outbound message to the transport. The default collects into
127
+ # #outbox; Stdio overrides this to write to $stdout as messages appear.
128
+ def deliver(message)
129
+ @outbox << message
130
+ end
131
+
132
+ # Adopt the protocol version a stateless (2026-07-28) request declares
133
+ # in `_meta`; that declaration is what unlocks the handlers without the
134
+ # legacy gate. A legacy request declares nothing, so the gate stands.
135
+ def adopt_version(version)
136
+ return if version.nil?
137
+
138
+ @protocol_version = version
139
+ @initialized = true
140
+ debug_log "Stateless request (protocol #{version})"
141
+ end
142
+
143
+ # server/discover (2026-07-28): advertise supported protocol versions,
144
+ # capabilities, and identity. Clients call it before anything else to
145
+ # select a version (or as a backward-compat probe on stdio).
146
+ def handle_discover(id)
147
+ send_result(id, {
148
+ protocolVersions: Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS,
149
+ capabilities: @capabilities,
150
+ serverInfo: { name: @name, version: @server_version }
151
+ })
152
+ debug_log "server/discover answered"
153
+ end
154
+
155
+ def handle_initialize(id, params)
156
+ @initialized = true
157
+ @protocol_version = params[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
158
+ client_version = params[:protocolVersion] || Ask::MCP::PROTOCOL_VERSION
159
+ debug_log "Handling initialize (id=#{id.inspect}, version=#{client_version})"
160
+ send_result(id, {
161
+ protocolVersion: client_version,
162
+ capabilities: @capabilities,
163
+ serverInfo: {
164
+ name: @name,
165
+ version: @server_version
166
+ }
167
+ })
168
+ debug_log "Initialize complete"
169
+ end
170
+
171
+ def handle_tools_list(id)
172
+ defs = @adapter.definitions
173
+ debug_log "tools/list returning #{defs.length} tool definitions"
174
+ send_result(id, cacheable({ tools: defs }))
175
+ end
176
+
177
+ def handle_resources_list(id)
178
+ defs = @resources.values.map { |r| resource_to_h(r) }
179
+ debug_log "resources/list returning #{defs.length} resources"
180
+ send_result(id, cacheable({ resources: defs }))
181
+ end
182
+
183
+ def handle_resources_templates_list(id)
184
+ defs = @resource_templates.values.map { |t| template_to_h(t) }
185
+ debug_log "resources/templates/list returning #{defs.length} templates"
186
+ send_result(id, cacheable({ resourceTemplates: defs }))
187
+ end
188
+
189
+ def handle_resource_read(id, params)
190
+ uri = params[:uri].to_s
191
+ resource = @resources[uri]
192
+ if resource.nil?
193
+ code = stateless_mode? ? Native::Messages::ErrorCodes::INVALID_PARAMS : Native::Messages::ErrorCodes::RESOURCE_NOT_FOUND
194
+ return send_error(id, code, "Resource not found: #{uri}")
195
+ end
196
+
197
+ contents = if resource.respond_to?(:content)
198
+ resource.content
199
+ elsif resource.respond_to?(:read)
200
+ resource.read
201
+ else
202
+ [{ uri: uri, text: "" }]
203
+ end
204
+ send_result(id, cacheable({ contents: contents }))
205
+ end
206
+
207
+ def handle_prompts_list(id)
208
+ defs = @prompts.values.map { |p| prompt_to_h(p) }
209
+ debug_log "prompts/list returning #{defs.length} prompts"
210
+ send_result(id, cacheable({ prompts: defs }))
211
+ end
212
+
213
+ def handle_prompt_get(id, params)
214
+ name = params[:name].to_s
215
+ prompt = @prompts[name]
216
+ if prompt.nil?
217
+ return send_error(id, Native::Messages::ErrorCodes::PROMPT_NOT_FOUND, "Prompt not found: #{name}")
218
+ end
219
+
220
+ messages = prompt.respond_to?(:messages) ? prompt.messages : []
221
+ send_result(id, { messages: messages })
222
+ end
223
+
224
+ def handle_tool_call(id, params)
225
+ cache_key = id.to_s
226
+
227
+ # Return cached result for retried requests (same ID, already processed)
228
+ if @result_cache.key?(cache_key)
229
+ debug_log "Returning cached result for id=#{id}"
230
+ return send_result(id, @result_cache[cache_key])
231
+ end
232
+
233
+ tool_name = params[:name].to_s
234
+ arguments = params[:arguments] || {}
235
+
236
+ debug_log "Handling tools/call: #{tool_name} (id=#{id.inspect})"
237
+
238
+ result = if @tool_timeout
239
+ Timeout.timeout(@tool_timeout) { @adapter.call(tool_name, arguments) }
240
+ else
241
+ @adapter.call(tool_name, arguments)
242
+ end
243
+
244
+ @result_cache[cache_key] = result
245
+ trim_cache
246
+
247
+ send_result(id, result)
248
+ rescue Timeout::Error
249
+ debug_log "Tool call timed out: #{tool_name}"
250
+ send_result(id, {
251
+ content: [{ type: "text", text: "Tool call timed out: #{tool_name}" }],
252
+ isError: true
253
+ })
254
+ end
255
+
256
+ # Serialize a resource object for resources/list. Prefers to_h (the
257
+ # Resource value object emits title/icons/description/mimeType);
258
+ # otherwise builds the shape from duck-typed accessors.
259
+ def resource_to_h(resource)
260
+ return resource.to_h if resource.respond_to?(:to_h)
261
+
262
+ h = { uri: resource.uri, name: resource.name }
263
+ h[:title] = resource.title if resource.respond_to?(:title) && resource.title
264
+ h[:description] = resource.description if resource.respond_to?(:description) && resource.description
265
+ h[:mimeType] = resource.mime_type if resource.respond_to?(:mime_type) && resource.mime_type
266
+ h[:icons] = resource.icons if resource.respond_to?(:icons) && resource.icons&.any?
267
+ h
268
+ end
269
+
270
+ def template_to_h(template)
271
+ return template.to_h if template.respond_to?(:to_h)
272
+
273
+ h = { uriTemplate: template.uri_template, name: template.name }
274
+ h[:title] = template.title if template.respond_to?(:title) && template.title
275
+ h[:mimeType] = template.mime_type if template.respond_to?(:mime_type) && template.mime_type
276
+ h[:icons] = template.icons if template.respond_to?(:icons) && template.icons&.any?
277
+ h
278
+ end
279
+
280
+ def prompt_to_h(prompt)
281
+ return prompt.to_h if prompt.respond_to?(:to_h)
282
+
283
+ h = { name: prompt.name }
284
+ h[:title] = prompt.title if prompt.respond_to?(:title) && prompt.title
285
+ h[:description] = prompt.description if prompt.respond_to?(:description) && prompt.description
286
+ h[:arguments] = prompt.arguments if prompt.respond_to?(:arguments) && prompt.arguments&.any?
287
+ h[:icons] = prompt.icons if prompt.respond_to?(:icons) && prompt.icons&.any?
288
+ h
289
+ end
290
+
291
+ def send_result(id, result)
292
+ result = result.merge(stateless_result_fields) if stateless_mode?
293
+ deliver({ jsonrpc: "2.0", id: id, result: result })
294
+ end
295
+
296
+ # 2026-07-28: every result carries `resultType`, and servers SHOULD
297
+ # identify themselves in `_meta` so a client can attribute an answer
298
+ # without a separate discovery round trip. Legacy peers tolerate both
299
+ # fields, but we only emit them for stateless peers so the legacy wire
300
+ # output is unchanged.
301
+ def stateless_result_fields
302
+ {
303
+ resultType: "complete",
304
+ _meta: { Native::Messages::Meta::SERVER_INFO_KEY => server_info }
305
+ }
306
+ end
307
+
308
+ def server_info
309
+ { name: @name, version: @server_version }
310
+ end
311
+
312
+ # Build a server→client notification (no id).
313
+ def send_notification(method, params = {})
314
+ msg = { jsonrpc: "2.0", method: method }
315
+ msg[:params] = params unless params.empty?
316
+ deliver(msg)
317
+ end
318
+
319
+ # 2026-07-28 CacheableResult: freshness hints (ttlMs) and scope
320
+ # (public/private) on list/read results so clients and shared
321
+ # intermediaries may cache them. Only emitted for stateless peers.
322
+ def cacheable(result)
323
+ return result unless stateless_mode?
324
+
325
+ result.merge(ttlMs: @cache_ttl_ms, cacheScope: @cache_scope)
326
+ end
327
+
328
+ # True once a 2026-07-28 stateless peer has been detected.
329
+ def stateless_mode?
330
+ @protocol_version == Ask::MCP::LATEST_PROTOCOL_VERSION
331
+ end
332
+
333
+ def supported_version?(version)
334
+ Ask::MCP::SUPPORTED_PROTOCOL_VERSIONS.include?(version)
335
+ end
336
+
337
+ def gated?(method)
338
+ GATED_METHODS.include?(method)
339
+ end
340
+
341
+ # Read the protocol version a stateless client advertises in params
342
+ # `_meta`. Returns nil for legacy requests. Handles both symbol and
343
+ # string key forms (the JSON parser symbolizes all keys).
344
+ def meta_protocol_version(params)
345
+ meta = params[:meta] || params[:_meta] || {}
346
+ meta_value(meta, Native::Messages::Meta::PROTOCOL_VERSION_KEY)
347
+ end
348
+
349
+ def meta_value(meta, key)
350
+ meta[key] || meta[key.to_sym] || meta[key.to_s]
351
+ end
352
+
353
+ def send_error(id, code, message)
354
+ deliver({ jsonrpc: "2.0", id: id, error: { code: code, message: message } })
355
+ end
356
+
357
+ def debug_log(msg)
358
+ return unless @debug
359
+
360
+ ts = Time.now.strftime("%H:%M:%S.%L")
361
+ warn "[#{ts}] [#{@name}] #{msg}"
362
+ end
363
+
364
+ def trim_cache
365
+ return if @result_cache.size <= MAX_RESULT_CACHE
366
+
367
+ @result_cache.shift(@result_cache.size - MAX_RESULT_CACHE)
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end