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 +4 -4
- data/lib/tina4/dev_admin.rb +48 -11
- data/lib/tina4/mcp.rb +120 -15
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: acbf145f4e91880ca9b0763788134071b914c5923f3cdc84fc459a333097bb11
|
|
4
|
+
data.tar.gz: 04e0793ef84e6d044e578d6183526c05c45edd3f68dd04f2c1801458296d130c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cd653a5682bf5f1257ae0290a36d5b6784afb16c67e5433b56480c25d750003fa3f9d1560aa1246c2ec71c7720ce0073cf9e1f10703b7e031effe3a5d36e29c
|
|
7
|
+
data.tar.gz: 17e2ccf01f62cd8b5ce17d7606c50f8e2882e7e6c1ad32fd0021ff980b7850fccda44a818c2370ae832387fae745e22fefbb510f49a0a7c4f187f31d4cc38356
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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/
|
|
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"]
|
|
663
|
-
with_mcp_gate(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
|
-
|
|
1687
|
-
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
#
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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" =>
|
|
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
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.
|
|
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-
|
|
11
|
+
date: 2026-07-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|