tina4ruby 3.13.50 → 3.13.51

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e5481c75ff116f52ef9981a60239741d14ebb21949d167e64760f6f618db2d0
4
- data.tar.gz: 1eef903d1d00d45d21beef778ca31ffc5400208bd0ebebf0271cbe660e2b86d7
3
+ metadata.gz: acbf145f4e91880ca9b0763788134071b914c5923f3cdc84fc459a333097bb11
4
+ data.tar.gz: 04e0793ef84e6d044e578d6183526c05c45edd3f68dd04f2c1801458296d130c
5
5
  SHA512:
6
- metadata.gz: afabfb4f602eab2b0e8f6d0be151f2f1bfb131f6c928d2627010b896b105fc6260f0d4647c42d07c5752a267c84222df2b080b8e7ac8c43b8406e8528832037c
7
- data.tar.gz: 1249983018db2293240fe6d66d2938197a8e00febdb0dd818bd490c077ac49e794ea5f70bf8601db5863db452306458fb95a4c6064e588d71e7e5b04719b56b0
6
+ metadata.gz: 6cd653a5682bf5f1257ae0290a36d5b6784afb16c67e5433b56480c25d750003fa3f9d1560aa1246c2ec71c7720ce0073cf9e1f10703b7e031effe3a5d36e29c
7
+ data.tar.gz: 17e2ccf01f62cd8b5ce17d7606c50f8e2882e7e6c1ad32fd0021ff980b7850fccda44a818c2370ae832387fae745e22fefbb510f49a0a7c4f187f31d4cc38356
@@ -339,12 +339,13 @@ module Tina4
339
339
  return unless enabled?
340
340
 
341
341
  port = ENV["TINA4_PORT"] || ENV["PORT"] || "7147"
342
- url = "http://localhost:#{port}/__dev/api/mcp"
342
+ url = "http://localhost:#{port}/__dev/mcp"
343
343
  target_dir = File.join(project_root, ".tina4")
344
344
  target = File.join(target_dir, "mcp.json")
345
345
  payload = {
346
346
  "mcpServers" => {
347
347
  "tina4-live-docs" => {
348
+ "type" => "http",
348
349
  "url" => url,
349
350
  "description" => "Live API docs for this Tina4 project (framework + user code)",
350
351
  },
@@ -659,8 +660,14 @@ module Tina4
659
660
  body = read_json_body(env) || {}
660
661
  json_response(mcp_tool_call(body))
661
662
  end
662
- when ["POST", "/__dev/mcp"], ["POST", "/__dev/mcp/message"]
663
- with_mcp_gate(env) { mcp_jsonrpc(env) }
663
+ when ["POST", "/__dev/mcp"]
664
+ with_mcp_gate(env) { mcp_streamable(env) }
665
+ when ["DELETE", "/__dev/mcp"]
666
+ with_mcp_gate(env) { mcp_delete(env) }
667
+ when ["GET", "/__dev/mcp"]
668
+ with_mcp_gate(env) { mcp_get_405 }
669
+ when ["POST", "/__dev/mcp/message"]
670
+ with_mcp_gate(env) { mcp_legacy_message(env) }
664
671
  when ["GET", "/__dev/mcp/sse"]
665
672
  with_mcp_gate(env) { mcp_sse_handshake }
666
673
  when ["GET", "/__dev/api/scaffold"]
@@ -1683,15 +1690,45 @@ module Tina4
1683
1690
  # clients POST a JSON-RPC 2.0 request; we hand the parsed body straight
1684
1691
  # to the default server's handle_message and echo the response. A
1685
1692
  # notification (no id) yields an empty 204, mirroring Python.
1686
- def mcp_jsonrpc(env)
1687
- body = read_json_body(env) || {}
1693
+ # Streamable HTTP POST /__dev/mcp — the current MCP transport. initialize
1694
+ # issues an Mcp-Session-Id; an unknown session on a non-initialize request
1695
+ # is a 404; a notification is 202; anything else answers inline (200).
1696
+ def mcp_streamable(env)
1688
1697
  server = Tina4._default_mcp_server
1689
- raw = server.handle_message(body)
1690
- if raw.nil? || raw.empty?
1691
- [204, { "content-type" => "application/json; charset=utf-8" }, []]
1692
- else
1693
- [200, { "content-type" => "application/json; charset=utf-8" }, [raw]]
1694
- end
1698
+ out = server.dispatch_http(read_json_body(env) || {}, mcp_session_id(env))
1699
+ mcp_triple(out)
1700
+ end
1701
+
1702
+ # Legacy HTTP+SSE POST /__dev/mcp/message inline, session-lenient.
1703
+ def mcp_legacy_message(env)
1704
+ server = Tina4._default_mcp_server
1705
+ out = server.dispatch_http(read_json_body(env) || {}, "")
1706
+ mcp_triple(out)
1707
+ end
1708
+
1709
+ # DELETE /__dev/mcp — terminate the session (Streamable HTTP spec).
1710
+ def mcp_delete(env)
1711
+ Tina4._default_mcp_server.close_session(mcp_session_id(env))
1712
+ [204, {}, []]
1713
+ end
1714
+
1715
+ # GET /__dev/mcp — a server->client stream we do not open here.
1716
+ def mcp_get_405
1717
+ [405,
1718
+ { "allow" => "POST, DELETE", "content-type" => "application/json; charset=utf-8" },
1719
+ [McpProtocol.encode_error(nil, McpProtocol::INVALID_REQUEST, "Method Not Allowed")]]
1720
+ end
1721
+
1722
+ # Read the Mcp-Session-Id request header (Rack: HTTP_MCP_SESSION_ID).
1723
+ def mcp_session_id(env)
1724
+ (env["HTTP_MCP_SESSION_ID"] || "").to_s
1725
+ end
1726
+
1727
+ # Turn a dispatch_http result hash into a Rack [status, headers, body].
1728
+ def mcp_triple(out)
1729
+ headers = { "content-type" => "application/json; charset=utf-8" }.merge(out[:headers] || {})
1730
+ body = out[:body].to_s
1731
+ [out[:status], headers, body.empty? ? [] : [body]]
1695
1732
  end
1696
1733
 
1697
1734
  # SSE handshake (GET /__dev/mcp/sse). Tells the client where to POST
data/lib/tina4/mcp.rb CHANGED
@@ -22,6 +22,7 @@ require "json"
22
22
  require "socket"
23
23
  require "fileutils"
24
24
  require "open3"
25
+ require "securerandom"
25
26
 
26
27
  module Tina4
27
28
  # ── JSON-RPC 2.0 codec ────────────────────────────────────────────
@@ -216,6 +217,12 @@ module Tina4
216
217
 
217
218
  # ── McpServer ─────────────────────────────────────────────────────
218
219
  class McpServer
220
+ # MCP protocol versions this server can speak, newest first. The 2025-*
221
+ # versions are the Streamable HTTP era; 2024-11-05 is the legacy HTTP+SSE
222
+ # transport we still accept for older clients (Claude Desktop et al.).
223
+ SUPPORTED_PROTOCOL_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
224
+ LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS.first
225
+
219
226
  attr_reader :path, :name, :version
220
227
 
221
228
  # Class-level registry of all MCP server instances
@@ -231,6 +238,10 @@ module Tina4
231
238
  @tools = {}
232
239
  @resources = {}
233
240
  @initialized = false
241
+ # Streamable HTTP sessions: id => opened-at epoch. `initialize` mints one
242
+ # (returned in the Mcp-Session-Id header); a request bearing an unknown id
243
+ # gets a 404 so the client re-initializes.
244
+ @sessions = {}
234
245
  self.class.instances << self
235
246
  end
236
247
 
@@ -292,23 +303,102 @@ module Tina4
292
303
  end
293
304
  end
294
305
 
295
- # Register HTTP routes for this MCP server on the Tina4 router.
306
+ # ── Streamable HTTP transport ─────────────────────────────────────
307
+ # Session lifecycle + protocol negotiation shared by every transport
308
+ # entry point so the wire behaviour is identical across all 4 frameworks.
309
+
310
+ # Mint a new session id and remember it. Called on `initialize`.
311
+ def open_session
312
+ sid = SecureRandom.hex(16)
313
+ @sessions[sid] = Time.now.to_f
314
+ sid
315
+ end
316
+
317
+ # True when +session_id+ was issued by this server and is still open.
318
+ def is_valid_session(session_id)
319
+ !session_id.to_s.empty? && @sessions.key?(session_id)
320
+ end
321
+
322
+ # Forget a session (client DELETE or SSE stream close). Returns true when a
323
+ # live session was actually removed.
324
+ def close_session(session_id)
325
+ !@sessions.delete(session_id).nil?
326
+ end
327
+
328
+ # Pick the protocol version to run on. Echo the client's requested version
329
+ # when we support it (proper negotiation), else fall back to the newest
330
+ # version we speak so an unversioned/old client still connects.
331
+ def negotiate_protocol_version(requested)
332
+ return requested if SUPPORTED_PROTOCOL_VERSIONS.include?(requested)
333
+
334
+ LATEST_PROTOCOL_VERSION
335
+ end
336
+
337
+ # Transport-agnostic Streamable HTTP POST handler. Every transport calls
338
+ # this so the wire behaviour stays identical:
339
+ # - `initialize` mints a session id, returned in the Mcp-Session-Id header.
340
+ # - a non-initialize request carrying an unknown session id is a 404
341
+ # (JSON-RPC error) so the client knows to re-initialize.
342
+ # - a notification / response-only POST (no id) yields 202 with an empty
343
+ # body.
344
+ # - anything else returns 200 with the JSON-RPC response as
345
+ # application/json (the MCP Streamable HTTP spec permits a POST that
346
+ # resolves to a single response to answer inline).
347
+ # Returns { status:, headers:, body: }.
348
+ def dispatch_http(raw_data, session_id = "")
349
+ is_init = peek_method(raw_data) == "initialize"
350
+ if !is_init && !session_id.to_s.empty? && !is_valid_session(session_id)
351
+ return {
352
+ status: 404,
353
+ headers: {},
354
+ body: McpProtocol.encode_error(nil, McpProtocol::INVALID_REQUEST, "session not found")
355
+ }
356
+ end
357
+
358
+ body = handle_message(raw_data)
359
+ headers = {}
360
+ headers["Mcp-Session-Id"] = open_session if is_init
361
+ return { status: 202, headers: headers, body: "" } if body.nil? || body.empty?
362
+
363
+ { status: 200, headers: headers, body: body }
364
+ end
365
+
366
+ # Register HTTP routes for this MCP server on the Tina4 router. Custom
367
+ # (developer-created) MCP servers use this; the built-in dev server mounts
368
+ # the same transport via DevAdmin's dispatcher.
296
369
  def register_routes(router = nil)
297
- server = self
298
- msg_path = "#{@path}/message"
299
- sse_path = "#{@path}/sse"
370
+ server = self
371
+ base_path = @path
372
+ msg_path = "#{@path}/message"
373
+ sse_path = "#{@path}/sse"
374
+
375
+ # Streamable HTTP (current transport) — single POST endpoint.
376
+ Tina4::Router.post(base_path) do |request, response|
377
+ out = server.dispatch_http(request.body, (request.header("mcp-session-id") || "").to_s)
378
+ out[:headers].each { |k, v| response.header(k, v) }
379
+ response.call(out[:body], out[:status], "application/json")
380
+ end
300
381
 
382
+ # DELETE terminates the session (Streamable HTTP spec).
383
+ Tina4::Router.delete(base_path) do |request, response|
384
+ server.close_session((request.header("mcp-session-id") || "").to_s)
385
+ response.call("", 204)
386
+ end
387
+
388
+ # GET on the endpoint is a server->client stream we do not open here.
389
+ Tina4::Router.get(base_path) do |_request, response|
390
+ response.header("allow", "POST, DELETE")
391
+ response.call({ "error" => "Method Not Allowed" }, 405, "application/json")
392
+ end
393
+
394
+ # Legacy HTTP+SSE POST target — inline, session-lenient.
301
395
  Tina4::Router.post(msg_path) do |request, response|
302
- body = request.body
303
- raw = body.is_a?(Hash) ? body : (body.is_a?(String) ? body : body.to_s)
304
- result = server.handle_message(raw)
305
- if result.nil? || result.empty?
306
- response.call("", 204)
307
- else
308
- response.call(JSON.parse(result))
309
- end
396
+ out = server.dispatch_http(request.body, "")
397
+ out[:headers].each { |k, v| response.header(k, v) }
398
+ response.call(out[:body], out[:status], "application/json")
310
399
  end
311
400
 
401
+ # Legacy HTTP+SSE handshake — one-shot endpoint event.
312
402
  Tina4::Router.get(sse_path) do |request, response|
313
403
  endpoint_url = "#{request.url.sub(%r{/sse\z}, "")}/message"
314
404
  sse_data = "event: endpoint\ndata: #{endpoint_url}\n\n"
@@ -334,7 +424,8 @@ module Tina4
334
424
  config["mcpServers"] ||= {}
335
425
  server_key = @name.downcase.gsub(" ", "-")
336
426
  config["mcpServers"][server_key] = {
337
- "url" => "http://localhost:#{port}#{@path}/sse"
427
+ "type" => "http",
428
+ "url" => "http://localhost:#{port}#{@path}"
338
429
  }
339
430
 
340
431
  File.write(config_file, JSON.pretty_generate(config) + "\n")
@@ -352,10 +443,24 @@ module Tina4
352
443
 
353
444
  private
354
445
 
355
- def _handle_initialize(_params)
446
+ # Read the JSON-RPC `method` from a raw request without dispatching. Used by
447
+ # the transport to spot `initialize` (mint a session) before handing the
448
+ # message to handle_message. Accepts a parsed Hash or a raw JSON string.
449
+ def peek_method(raw_data)
450
+ obj = if raw_data.is_a?(Hash)
451
+ raw_data
452
+ else
453
+ str = raw_data.to_s
454
+ str.empty? ? {} : (JSON.parse(str) rescue nil)
455
+ end
456
+ obj.is_a?(Hash) ? obj["method"] : nil
457
+ end
458
+
459
+ def _handle_initialize(params)
356
460
  @initialized = true
461
+ requested = params.is_a?(Hash) ? params["protocolVersion"] : nil
357
462
  {
358
- "protocolVersion" => "2024-11-05",
463
+ "protocolVersion" => negotiate_protocol_version(requested),
359
464
  "capabilities" => {
360
465
  "tools" => { "listChanged" => false },
361
466
  "resources" => { "subscribe" => false, "listChanged" => false }
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.50"
4
+ VERSION = "3.13.51"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.50
4
+ version: 3.13.51
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-02 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack