tina4ruby 3.10.31 → 3.10.32

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: f58284436d1c4f21bf16c723f02d174c6b4090395eb59a8357387b776cdb3c07
4
- data.tar.gz: e19f24eac4a4010d9377a74f7e5da9ab437c062b3c4773f1a0932995b661e298
3
+ metadata.gz: e75cf8a64755d61b6f6037110a61dfd2183bc46c7e77ce04aba13af145a64268
4
+ data.tar.gz: f6d0b3e2f5bd0133acbcad91d322ae9ab3410c62eb2847f0edff5aeef94cc4f3
5
5
  SHA512:
6
- metadata.gz: 9191ea613953492469a000a221cc0c18f819eb48493c6a3128b04f5ef1e85d99ab08c29fe38ebbdb3b1fdfc85db1eaa648c6d327888d0f57ad19af7a14f4155d
7
- data.tar.gz: 7d431e996a22db4a25879f148e891ceaeb6a7b8e186e2e5c9ccecb631234122c8a463b35f4bf4c963435973dc1b6c58dd819053ae2966917742c2baa0860a45d
6
+ metadata.gz: 16c4159dd47a39207e5d48a9b4830e884d4d7ea255c7c1dd3b33f6f03e7734d70445d4de57da16a64f8c3080c6ff5218de19b2009823b3f19f56eff8d72c661e
7
+ data.tar.gz: ca9b35153be0b03cd92d56d51979a2532ff87eef0a4f5316bd28c050b0e591fa7bea81a7c5b5688749056b9873f5956a9136c0a5b6246e8ab3016ac54998e08b
data/lib/tina4/mcp.rb ADDED
@@ -0,0 +1,696 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tina4 MCP Server -- Model Context Protocol for AI tool integration.
4
+ #
5
+ # Built-in MCP server for dev tools + developer API for custom MCP servers.
6
+ #
7
+ # Usage (developer):
8
+ #
9
+ # mcp = Tina4::McpServer.new("/my-mcp", name: "My App Tools")
10
+ #
11
+ # Tina4.mcp_tool("lookup_invoice", description: "Find invoice by number", server: mcp) do |invoice_no:|
12
+ # db.fetch_one("SELECT * FROM invoices WHERE invoice_no = ?", [invoice_no])
13
+ # end
14
+ #
15
+ # Tina4.mcp_resource("app://schema", description: "Database schema", server: mcp) do
16
+ # db.tables
17
+ # end
18
+ #
19
+ # Built-in dev tools auto-register when TINA4_DEBUG=true and running on localhost.
20
+
21
+ require "json"
22
+ require "socket"
23
+ require "fileutils"
24
+
25
+ module Tina4
26
+ # ── JSON-RPC 2.0 codec ────────────────────────────────────────────
27
+ module McpProtocol
28
+ # Standard JSON-RPC 2.0 error codes
29
+ PARSE_ERROR = -32_700
30
+ INVALID_REQUEST = -32_600
31
+ METHOD_NOT_FOUND = -32_601
32
+ INVALID_PARAMS = -32_602
33
+ INTERNAL_ERROR = -32_603
34
+
35
+ # Encode a successful JSON-RPC 2.0 response.
36
+ def self.encode_response(request_id, result)
37
+ JSON.generate({
38
+ "jsonrpc" => "2.0",
39
+ "id" => request_id,
40
+ "result" => result
41
+ })
42
+ end
43
+
44
+ # Encode a JSON-RPC 2.0 error response.
45
+ def self.encode_error(request_id, code, message, data = nil)
46
+ error = { "code" => code, "message" => message }
47
+ error["data"] = data unless data.nil?
48
+ JSON.generate({
49
+ "jsonrpc" => "2.0",
50
+ "id" => request_id,
51
+ "error" => error
52
+ })
53
+ end
54
+
55
+ # Encode a JSON-RPC 2.0 notification (no id).
56
+ def self.encode_notification(method, params = nil)
57
+ msg = { "jsonrpc" => "2.0", "method" => method }
58
+ msg["params"] = params unless params.nil?
59
+ JSON.generate(msg)
60
+ end
61
+
62
+ # Decode a JSON-RPC 2.0 request.
63
+ #
64
+ # @return [Array<(String, Hash, Object)>] method, params, request_id
65
+ # @raise [ArgumentError] if the message is malformed
66
+ def self.decode_request(data)
67
+ case data
68
+ when String
69
+ begin
70
+ msg = JSON.parse(data)
71
+ rescue JSON::ParserError => e
72
+ raise ArgumentError, "Invalid JSON: #{e.message}"
73
+ end
74
+ when Hash
75
+ msg = data
76
+ else
77
+ raise ArgumentError, "Message must be a String or Hash"
78
+ end
79
+
80
+ raise ArgumentError, "Message must be a JSON object" unless msg.is_a?(Hash)
81
+ raise ArgumentError, "Missing or invalid jsonrpc version" unless msg["jsonrpc"] == "2.0"
82
+
83
+ method = msg["method"]
84
+ raise ArgumentError, "Missing or invalid method" if method.nil? || !method.is_a?(String) || method.empty?
85
+
86
+ params = msg.fetch("params", {})
87
+ request_id = msg["id"] # nil for notifications
88
+
89
+ [method, params, request_id]
90
+ end
91
+ end
92
+
93
+ # ── Type mapping ──────────────────────────────────────────────────
94
+ TYPE_MAP = {
95
+ "String" => "string",
96
+ "Integer" => "integer",
97
+ "Float" => "number",
98
+ "Numeric" => "number",
99
+ "TrueClass" => "boolean",
100
+ "FalseClass"=> "boolean",
101
+ "Array" => "array",
102
+ "Hash" => "object"
103
+ }.freeze
104
+
105
+ # Extract JSON Schema input schema from a Ruby method's parameters.
106
+ def self.schema_from_method(method_obj)
107
+ properties = {}
108
+ required = []
109
+
110
+ method_obj.parameters.each do |kind, name|
111
+ next if name == :self
112
+ name_s = name.to_s
113
+
114
+ # Default type is "string" -- Ruby doesn't have inline type annotations
115
+ prop = { "type" => "string" }
116
+
117
+ case kind
118
+ when :req, :keyreq
119
+ required << name_s
120
+ when :opt, :key
121
+ # Has a default -- we cannot inspect the default value easily in Ruby,
122
+ # so we just mark it as optional (no "default" key)
123
+ end
124
+
125
+ properties[name_s] = prop
126
+ end
127
+
128
+ schema = { "type" => "object", "properties" => properties }
129
+ schema["required"] = required unless required.empty?
130
+ schema
131
+ end
132
+
133
+ # Check if the server is running on localhost.
134
+ def self.is_localhost?
135
+ host = ENV.fetch("HOST_NAME", "localhost:7145").split(":").first
136
+ ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
137
+ end
138
+
139
+ # ── McpServer ─────────────────────────────────────────────────────
140
+ class McpServer
141
+ attr_reader :path, :name, :version
142
+
143
+ # Class-level registry of all MCP server instances
144
+ @instances = []
145
+ class << self
146
+ attr_reader :instances
147
+ end
148
+
149
+ def initialize(path, name: "Tina4 MCP", version: "1.0.0")
150
+ @path = path.chomp("/")
151
+ @name = name
152
+ @version = version
153
+ @tools = {}
154
+ @resources = {}
155
+ @initialized = false
156
+ self.class.instances << self
157
+ end
158
+
159
+ # Register a tool callable.
160
+ #
161
+ # @param name [String]
162
+ # @param handler [Method, Proc, #call] the callable
163
+ # @param description [String]
164
+ # @param schema [Hash, nil] override auto-detected schema
165
+ def register_tool(name, handler, description = "", schema = nil)
166
+ schema ||= Tina4.schema_from_method(handler)
167
+ @tools[name] = {
168
+ "name" => name,
169
+ "description" => description.empty? ? name : description,
170
+ "inputSchema" => schema,
171
+ "handler" => handler
172
+ }
173
+ end
174
+
175
+ # Register a resource URI.
176
+ def register_resource(uri, handler, description = "", mime_type = "application/json")
177
+ @resources[uri] = {
178
+ "uri" => uri,
179
+ "name" => description.empty? ? uri : description,
180
+ "description" => description.empty? ? uri : description,
181
+ "mimeType" => mime_type,
182
+ "handler" => handler
183
+ }
184
+ end
185
+
186
+ # Process an incoming JSON-RPC message and return the response string.
187
+ def handle_message(raw_data)
188
+ begin
189
+ method, params, request_id = McpProtocol.decode_request(raw_data)
190
+ rescue ArgumentError => e
191
+ return McpProtocol.encode_error(nil, McpProtocol::PARSE_ERROR, e.message)
192
+ end
193
+
194
+ handler_method = {
195
+ "initialize" => :_handle_initialize,
196
+ "notifications/initialized" => :_handle_initialized,
197
+ "tools/list" => :_handle_tools_list,
198
+ "tools/call" => :_handle_tools_call,
199
+ "resources/list" => :_handle_resources_list,
200
+ "resources/read" => :_handle_resources_read,
201
+ "ping" => :_handle_ping
202
+ }[method]
203
+
204
+ if handler_method.nil?
205
+ return McpProtocol.encode_error(request_id, McpProtocol::METHOD_NOT_FOUND, "Method not found: #{method}")
206
+ end
207
+
208
+ begin
209
+ result = send(handler_method, params)
210
+ return "" if request_id.nil? # Notification -- no response
211
+ McpProtocol.encode_response(request_id, result)
212
+ rescue => e
213
+ McpProtocol.encode_error(request_id, McpProtocol::INTERNAL_ERROR, e.message)
214
+ end
215
+ end
216
+
217
+ # Register HTTP routes for this MCP server on the Tina4 router.
218
+ def register_routes
219
+ server = self
220
+ msg_path = "#{@path}/message"
221
+ sse_path = "#{@path}/sse"
222
+
223
+ Tina4::Router.post(msg_path) do |request, response|
224
+ body = request.body
225
+ raw = body.is_a?(Hash) ? body : (body.is_a?(String) ? body : body.to_s)
226
+ result = server.handle_message(raw)
227
+ if result.nil? || result.empty?
228
+ response.call("", 204)
229
+ else
230
+ response.call(JSON.parse(result))
231
+ end
232
+ end
233
+
234
+ Tina4::Router.get(sse_path) do |request, response|
235
+ endpoint_url = "#{request.url.sub(%r{/sse\z}, "")}/message"
236
+ sse_data = "event: endpoint\ndata: #{endpoint_url}\n\n"
237
+ response.call(sse_data, 200, "text/event-stream")
238
+ end
239
+ end
240
+
241
+ # Write/update .claude/settings.json with this MCP server config.
242
+ def write_claude_config(port = 7145)
243
+ config_dir = File.join(Dir.pwd, ".claude")
244
+ FileUtils.mkdir_p(config_dir)
245
+ config_file = File.join(config_dir, "settings.json")
246
+
247
+ config = {}
248
+ if File.exist?(config_file)
249
+ begin
250
+ config = JSON.parse(File.read(config_file))
251
+ rescue JSON::ParserError, IOError
252
+ # ignore corrupt file
253
+ end
254
+ end
255
+
256
+ config["mcpServers"] ||= {}
257
+ server_key = @name.downcase.gsub(" ", "-")
258
+ config["mcpServers"][server_key] = {
259
+ "url" => "http://localhost:#{port}#{@path}/sse"
260
+ }
261
+
262
+ File.write(config_file, JSON.pretty_generate(config) + "\n")
263
+ end
264
+
265
+ # Access registered tools (for testing)
266
+ def tools
267
+ @tools
268
+ end
269
+
270
+ # Access registered resources (for testing)
271
+ def resources
272
+ @resources
273
+ end
274
+
275
+ private
276
+
277
+ def _handle_initialize(_params)
278
+ @initialized = true
279
+ {
280
+ "protocolVersion" => "2024-11-05",
281
+ "capabilities" => {
282
+ "tools" => { "listChanged" => false },
283
+ "resources" => { "subscribe" => false, "listChanged" => false }
284
+ },
285
+ "serverInfo" => {
286
+ "name" => @name,
287
+ "version" => @version
288
+ }
289
+ }
290
+ end
291
+
292
+ def _handle_initialized(_params)
293
+ nil
294
+ end
295
+
296
+ def _handle_ping(_params)
297
+ {}
298
+ end
299
+
300
+ def _handle_tools_list(_params)
301
+ tools_list = @tools.values.map do |t|
302
+ {
303
+ "name" => t["name"],
304
+ "description" => t["description"],
305
+ "inputSchema" => t["inputSchema"]
306
+ }
307
+ end
308
+ { "tools" => tools_list }
309
+ end
310
+
311
+ def _handle_tools_call(params)
312
+ tool_name = params["name"]
313
+ raise ArgumentError, "Missing tool name" if tool_name.nil? || tool_name.empty?
314
+
315
+ tool = @tools[tool_name]
316
+ raise ArgumentError, "Unknown tool: #{tool_name}" if tool.nil?
317
+
318
+ arguments = params.fetch("arguments", {})
319
+ handler = tool["handler"]
320
+
321
+ # Call the handler -- support both keyword and positional args
322
+ result = _invoke_handler(handler, arguments)
323
+
324
+ # Format result as MCP content
325
+ content = case result
326
+ when String
327
+ [{ "type" => "text", "text" => result }]
328
+ when Hash, Array
329
+ [{ "type" => "text", "text" => JSON.pretty_generate(result) }]
330
+ else
331
+ [{ "type" => "text", "text" => result.to_s }]
332
+ end
333
+
334
+ { "content" => content }
335
+ end
336
+
337
+ def _handle_resources_list(_params)
338
+ resources_list = @resources.values.map do |r|
339
+ {
340
+ "uri" => r["uri"],
341
+ "name" => r["name"],
342
+ "description" => r["description"],
343
+ "mimeType" => r["mimeType"]
344
+ }
345
+ end
346
+ { "resources" => resources_list }
347
+ end
348
+
349
+ def _handle_resources_read(params)
350
+ uri = params["uri"]
351
+ raise ArgumentError, "Missing resource URI" if uri.nil? || uri.empty?
352
+
353
+ resource = @resources[uri]
354
+ raise ArgumentError, "Unknown resource: #{uri}" if resource.nil?
355
+
356
+ result = resource["handler"].call
357
+
358
+ text = case result
359
+ when String then result
360
+ when Hash, Array then JSON.pretty_generate(result)
361
+ else result.to_s
362
+ end
363
+
364
+ {
365
+ "contents" => [{
366
+ "uri" => uri,
367
+ "mimeType" => resource["mimeType"],
368
+ "text" => text
369
+ }]
370
+ }
371
+ end
372
+
373
+ # Invoke a handler with arguments, supporting keyword args, positional args, and procs.
374
+ def _invoke_handler(handler, arguments)
375
+ if handler.is_a?(Proc) || handler.is_a?(Method)
376
+ params = handler.parameters
377
+ has_keywords = params.any? { |kind, _| [:key, :keyreq, :keyrest].include?(kind) }
378
+
379
+ if has_keywords
380
+ # Convert string keys to symbols for keyword args
381
+ kwargs = arguments.transform_keys(&:to_sym)
382
+ handler.call(**kwargs)
383
+ elsif params.any? { |kind, _| [:req, :opt].include?(kind) }
384
+ # Positional args -- pass values in parameter order
385
+ args = params.select { |kind, _| [:req, :opt].include?(kind) }
386
+ .map { |_, name| arguments[name.to_s] }
387
+ handler.call(*args)
388
+ else
389
+ handler.call
390
+ end
391
+ else
392
+ handler.call(**arguments.transform_keys(&:to_sym))
393
+ end
394
+ end
395
+ end
396
+
397
+ # ── Decorator-style API ───────────────────────────────────────────
398
+
399
+ @_default_mcp_server = nil
400
+
401
+ def self._default_mcp_server
402
+ @_default_mcp_server ||= McpServer.new("/__dev/mcp", name: "Tina4 Dev Tools")
403
+ end
404
+
405
+ # Register a block as an MCP tool.
406
+ #
407
+ # Tina4.mcp_tool("lookup_invoice", description: "Find invoice by number") do |invoice_no:|
408
+ # db.fetch_one("SELECT * FROM invoices WHERE invoice_no = ?", [invoice_no])
409
+ # end
410
+ def self.mcp_tool(name, description: "", server: nil, &block)
411
+ target = server || _default_mcp_server
412
+ handler = block
413
+ tool_desc = description.empty? ? name : description
414
+ target.register_tool(name, handler, tool_desc)
415
+ handler
416
+ end
417
+
418
+ # Register a block as an MCP resource.
419
+ #
420
+ # Tina4.mcp_resource("app://tables", description: "Database tables") do
421
+ # db.tables
422
+ # end
423
+ def self.mcp_resource(uri, description: "", mime_type: "application/json", server: nil, &block)
424
+ target = server || _default_mcp_server
425
+ target.register_resource(uri, block, description, mime_type)
426
+ block
427
+ end
428
+
429
+ # ── Built-in dev tools ────────────────────────────────────────────
430
+ module McpDevTools
431
+ # Register all 24 built-in dev tools on the given McpServer.
432
+ def self.register(server)
433
+ project_root = File.expand_path(Dir.pwd)
434
+
435
+ # ── Helpers ────────────────────────────────────────
436
+ safe_path = lambda do |rel_path|
437
+ resolved = File.expand_path(rel_path, project_root)
438
+ unless resolved.start_with?(project_root)
439
+ raise ArgumentError, "Path escapes project directory: #{rel_path}"
440
+ end
441
+ resolved
442
+ end
443
+
444
+ redact_env = lambda do |key, value|
445
+ sensitive = %w[secret password token key credential api_key]
446
+ if sensitive.any? { |s| key.downcase.include?(s) }
447
+ "***REDACTED***"
448
+ else
449
+ value
450
+ end
451
+ end
452
+
453
+ # ── Database Tools ────────────────────────────────
454
+ server.register_tool("database_query", lambda { |sql:, params: "[]"|
455
+ db = Tina4.database
456
+ return { "error" => "No database connection" } if db.nil?
457
+ param_list = params.is_a?(String) ? JSON.parse(params) : params
458
+ result = db.fetch(sql, param_list)
459
+ { "records" => result.to_a, "count" => result.count }
460
+ }, "Execute a read-only SQL query (SELECT)")
461
+
462
+ server.register_tool("database_execute", lambda { |sql:, params: "[]"|
463
+ db = Tina4.database
464
+ return { "error" => "No database connection" } if db.nil?
465
+ param_list = params.is_a?(String) ? JSON.parse(params) : params
466
+ result = db.execute(sql, param_list)
467
+ db.commit rescue nil
468
+ { "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
469
+ }, "Execute arbitrary SQL (INSERT/UPDATE/DELETE/DDL)")
470
+
471
+ server.register_tool("database_tables", lambda {
472
+ db = Tina4.database
473
+ return { "error" => "No database connection" } if db.nil?
474
+ db.tables
475
+ }, "List all database tables")
476
+
477
+ server.register_tool("database_columns", lambda { |table:|
478
+ db = Tina4.database
479
+ return { "error" => "No database connection" } if db.nil?
480
+ db.columns(table)
481
+ }, "Get column definitions for a table")
482
+
483
+ # ── Route Tools ───────────────────────────────────
484
+ server.register_tool("route_list", lambda {
485
+ routes = Tina4::Router.routes
486
+ routes.map do |route|
487
+ {
488
+ "method" => route[:method].to_s,
489
+ "path" => route[:path].to_s,
490
+ "auth_required" => !route[:auth_handler].nil?
491
+ }
492
+ end
493
+ }, "List all registered routes")
494
+
495
+ server.register_tool("route_test", lambda { |method:, path:, body: "", headers: "{}"|
496
+ client = Tina4::TestClient.new
497
+ header_hash = headers.is_a?(String) ? JSON.parse(headers) : headers
498
+ m = method.upcase
499
+ r = case m
500
+ when "GET" then client.get(path, headers: header_hash)
501
+ when "POST" then client.post(path, body: body, headers: header_hash)
502
+ when "PUT" then client.put(path, body: body, headers: header_hash)
503
+ when "DELETE" then client.delete(path, headers: header_hash)
504
+ else return { "error" => "Unsupported method: #{method}" }
505
+ end
506
+ { "status" => r.status, "body" => r.body, "content_type" => r.content_type }
507
+ }, "Call a route and return the response")
508
+
509
+ server.register_tool("swagger_spec", lambda {
510
+ Tina4::Swagger.generate
511
+ }, "Return the OpenAPI 3.0.3 JSON spec")
512
+
513
+ # ── Template Tools ────────────────────────────────
514
+ server.register_tool("template_render", lambda { |template:, data: "{}"|
515
+ ctx = data.is_a?(String) ? JSON.parse(data) : data
516
+ Tina4::Template.render_string(template, ctx)
517
+ }, "Render a template string with data")
518
+
519
+ # ── File Tools ────────────────────────────────────
520
+ server.register_tool("file_read", lambda { |path:|
521
+ p = safe_path.call(path)
522
+ return "File not found: #{path}" unless File.exist?(p)
523
+ return "Not a file: #{path}" unless File.file?(p)
524
+ File.read(p, encoding: "utf-8")
525
+ }, "Read a project file")
526
+
527
+ server.register_tool("file_write", lambda { |path:, content:|
528
+ p = safe_path.call(path)
529
+ FileUtils.mkdir_p(File.dirname(p))
530
+ File.write(p, content, encoding: "utf-8")
531
+ rel = p.sub("#{project_root}/", "")
532
+ { "written" => rel, "bytes" => content.bytesize }
533
+ }, "Write or update a project file")
534
+
535
+ server.register_tool("file_list", lambda { |path: "."|
536
+ p = safe_path.call(path)
537
+ return { "error" => "Directory not found: #{path}" } unless File.exist?(p)
538
+ return { "error" => "Not a directory: #{path}" } unless File.directory?(p)
539
+ Dir.children(p).sort.map do |entry|
540
+ full = File.join(p, entry)
541
+ {
542
+ "name" => entry,
543
+ "type" => File.directory?(full) ? "dir" : "file",
544
+ "size" => File.file?(full) ? File.size(full) : 0
545
+ }
546
+ end
547
+ }, "List files in a directory")
548
+
549
+ server.register_tool("asset_upload", lambda { |filename:, content:, encoding: "utf-8"|
550
+ target = safe_path.call("src/public/#{filename}")
551
+ FileUtils.mkdir_p(File.dirname(target))
552
+ if encoding == "base64"
553
+ require "base64"
554
+ File.binwrite(target, Base64.decode64(content))
555
+ else
556
+ File.write(target, content, encoding: "utf-8")
557
+ end
558
+ rel = target.sub("#{project_root}/", "")
559
+ { "uploaded" => rel, "bytes" => File.size(target) }
560
+ }, "Upload a file to src/public/")
561
+
562
+ # ── Migration Tools ───────────────────────────────
563
+ server.register_tool("migration_status", lambda {
564
+ db = Tina4.database
565
+ return { "error" => "No database connection" } if db.nil?
566
+ migration = Tina4::Migration.new(db)
567
+ migration.respond_to?(:status) ? migration.status : { "info" => "Migration status not available" }
568
+ }, "List pending and completed migrations")
569
+
570
+ server.register_tool("migration_create", lambda { |description:|
571
+ migration = Tina4::Migration.new(nil)
572
+ filename = migration.create(description)
573
+ { "created" => filename }
574
+ }, "Create a new migration file")
575
+
576
+ server.register_tool("migration_run", lambda {
577
+ db = Tina4.database
578
+ return { "error" => "No database connection" } if db.nil?
579
+ migration = Tina4::Migration.new(db)
580
+ result = migration.run
581
+ { "result" => result.to_s }
582
+ }, "Run all pending migrations")
583
+
584
+ # ── Queue Tools ───────────────────────────────────
585
+ server.register_tool("queue_status", lambda { |topic: "default"|
586
+ begin
587
+ q = Tina4::Queue.new(topic: topic)
588
+ {
589
+ "topic" => topic,
590
+ "pending" => q.size("pending"),
591
+ "completed" => q.size("completed"),
592
+ "failed" => q.size("failed")
593
+ }
594
+ rescue => e
595
+ { "error" => e.message }
596
+ end
597
+ }, "Get queue size by status")
598
+
599
+ # ── Session/Cache Tools ───────────────────────────
600
+ server.register_tool("session_list", lambda {
601
+ session_dir = File.join("data", "sessions")
602
+ return [] unless File.directory?(session_dir)
603
+ Dir.glob(File.join(session_dir, "*.json")).map do |f|
604
+ begin
605
+ data = JSON.parse(File.read(f))
606
+ { "id" => File.basename(f, ".json"), "data" => data }
607
+ rescue JSON::ParserError, IOError
608
+ { "id" => File.basename(f, ".json"), "error" => "corrupt" }
609
+ end
610
+ end
611
+ }, "List active sessions")
612
+
613
+ server.register_tool("cache_stats", lambda {
614
+ begin
615
+ if defined?(Tina4::ResponseCache)
616
+ cache = Tina4::ResponseCache.new
617
+ cache.cache_stats
618
+ else
619
+ { "error" => "Response cache not available" }
620
+ end
621
+ rescue => e
622
+ { "error" => e.message }
623
+ end
624
+ }, "Get response cache statistics")
625
+
626
+ # ── ORM Tools ─────────────────────────────────────
627
+ server.register_tool("orm_describe", lambda {
628
+ models = []
629
+ Tina4::ORM.subclasses.each do |cls|
630
+ fields = cls.field_definitions.map do |name, field|
631
+ {
632
+ "name" => name.to_s,
633
+ "type" => field[:type].to_s,
634
+ "primary_key" => field[:primary_key] == true
635
+ }
636
+ end
637
+ models << {
638
+ "class" => cls.name,
639
+ "table" => cls.respond_to?(:table_name) ? cls.table_name : cls.name.downcase,
640
+ "fields" => fields
641
+ }
642
+ end
643
+ models
644
+ }, "List all ORM models with fields and types")
645
+
646
+ # ── Debugging Tools ───────────────────────────────
647
+ server.register_tool("log_tail", lambda { |lines: 50|
648
+ log_file = File.join("logs", "debug.log")
649
+ return [] unless File.exist?(log_file)
650
+ all_lines = File.read(log_file, encoding: "utf-8").split("\n")
651
+ all_lines.last([lines.to_i, all_lines.length].min)
652
+ }, "Read recent log entries")
653
+
654
+ server.register_tool("error_log", lambda { |limit: 20|
655
+ begin
656
+ if defined?(Tina4::DevAdmin) && Tina4::DevAdmin.respond_to?(:message_log)
657
+ log = Tina4::DevAdmin.message_log
658
+ log.respond_to?(:get) ? log.get(category: "error").first(limit.to_i) : []
659
+ else
660
+ []
661
+ end
662
+ rescue
663
+ []
664
+ end
665
+ }, "Recent errors and exceptions")
666
+
667
+ server.register_tool("env_list", lambda {
668
+ ENV.sort.to_h { |k, v| [k, redact_env.call(k, v)] }
669
+ }, "List environment variables (secrets redacted)")
670
+
671
+ # ── Data Tools ────────────────────────────────────
672
+ server.register_tool("seed_table", lambda { |table:, count: 10|
673
+ begin
674
+ db = Tina4.database
675
+ return { "error" => "No database connection" } if db.nil?
676
+ inserted = Tina4.seed_table(table, db.columns(table), count: count.to_i)
677
+ { "table" => table, "inserted" => inserted }
678
+ rescue => e
679
+ { "error" => e.message }
680
+ end
681
+ }, "Seed a table with fake data")
682
+
683
+ # ── System Tools ──────────────────────────────────
684
+ server.register_tool("system_info", lambda {
685
+ {
686
+ "framework" => "tina4-ruby",
687
+ "version" => (defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"),
688
+ "ruby" => RUBY_DESCRIPTION,
689
+ "platform" => RUBY_PLATFORM,
690
+ "cwd" => project_root,
691
+ "debug" => ENV.fetch("TINA4_DEBUG", "false")
692
+ }
693
+ }, "Framework version, Ruby version, project info")
694
+ end
695
+ end
696
+ end
@@ -0,0 +1,159 @@
1
+ # Tina4 Test Client — Test routes without starting a server.
2
+ #
3
+ # Usage:
4
+ #
5
+ # client = Tina4::TestClient.new
6
+ #
7
+ # response = client.get("/api/users")
8
+ # assert_equal 200, response.status
9
+ # assert response.json["users"]
10
+ #
11
+ # response = client.post("/api/users", json: { name: "Alice" })
12
+ # assert_equal 201, response.status
13
+ #
14
+ # response = client.get("/api/users/1", headers: { "Authorization" => "Bearer token123" })
15
+ #
16
+ module Tina4
17
+ class TestResponse
18
+ attr_reader :status, :body, :headers, :content_type
19
+
20
+ # Build from a Rack response tuple [status, headers, body_array]
21
+ def initialize(rack_response)
22
+ @status = rack_response[0]
23
+ @headers = rack_response[1] || {}
24
+ @content_type = @headers["content-type"] || ""
25
+ raw_body = rack_response[2]
26
+ @body = raw_body.is_a?(Array) ? raw_body.join : raw_body.to_s
27
+ end
28
+
29
+ # Parse body as JSON.
30
+ def json
31
+ return nil if @body.nil? || @body.empty?
32
+ JSON.parse(@body)
33
+ rescue JSON::ParserError
34
+ nil
35
+ end
36
+
37
+ # Return body as a string.
38
+ def text
39
+ @body.to_s
40
+ end
41
+
42
+ def inspect
43
+ "<TestResponse status=#{@status} content_type=#{@content_type.inspect}>"
44
+ end
45
+ end
46
+
47
+ class TestClient
48
+ # Send a GET request.
49
+ def get(path, headers: nil)
50
+ request("GET", path, headers: headers)
51
+ end
52
+
53
+ # Send a POST request.
54
+ def post(path, json: nil, body: nil, headers: nil)
55
+ request("POST", path, json: json, body: body, headers: headers)
56
+ end
57
+
58
+ # Send a PUT request.
59
+ def put(path, json: nil, body: nil, headers: nil)
60
+ request("PUT", path, json: json, body: body, headers: headers)
61
+ end
62
+
63
+ # Send a PATCH request.
64
+ def patch(path, json: nil, body: nil, headers: nil)
65
+ request("PATCH", path, json: json, body: body, headers: headers)
66
+ end
67
+
68
+ # Send a DELETE request.
69
+ def delete(path, headers: nil)
70
+ request("DELETE", path, headers: headers)
71
+ end
72
+
73
+ private
74
+
75
+ # Build a mock Rack env, match the route, execute the handler.
76
+ def request(method, path, json: nil, body: nil, headers: nil)
77
+ # Build raw body
78
+ raw_body = ""
79
+ content_type = ""
80
+
81
+ if json
82
+ raw_body = JSON.generate(json)
83
+ content_type = "application/json"
84
+ elsif body
85
+ raw_body = body.to_s
86
+ end
87
+
88
+ # Split path and query string
89
+ clean_path, query_string = path.include?("?") ? path.split("?", 2) : [path, ""]
90
+
91
+ # Build Rack env hash
92
+ env = {
93
+ "REQUEST_METHOD" => method.upcase,
94
+ "PATH_INFO" => clean_path,
95
+ "QUERY_STRING" => query_string || "",
96
+ "SERVER_NAME" => "localhost",
97
+ "SERVER_PORT" => "7145",
98
+ "HTTP_HOST" => "localhost:7145",
99
+ "REMOTE_ADDR" => "127.0.0.1",
100
+ "rack.input" => StringIO.new(raw_body),
101
+ "rack.url_scheme" => "http"
102
+ }
103
+
104
+ # Add content type
105
+ env["CONTENT_TYPE"] = content_type unless content_type.empty?
106
+ env["CONTENT_LENGTH"] = raw_body.bytesize.to_s unless raw_body.empty?
107
+
108
+ # Add custom headers (convert to Rack format: X-Custom → HTTP_X_CUSTOM)
109
+ if headers
110
+ headers.each do |key, value|
111
+ rack_key = "HTTP_#{key.upcase.tr('-', '_')}"
112
+ env[rack_key] = value
113
+ end
114
+ end
115
+
116
+ # Match route
117
+ result = Tina4::Router.find_route(clean_path, method.upcase)
118
+
119
+ unless result
120
+ return TestResponse.new([404, { "content-type" => "application/json" }, ['{"error":"Not found"}']])
121
+ end
122
+
123
+ route, path_params = result
124
+
125
+ # Create request and response
126
+ req = Tina4::Request.new(env, path_params || {})
127
+ res = Tina4::Response.new
128
+
129
+ # Build handler args (same logic as RackApp.handle_route)
130
+ handler_params = route.handler.parameters.map(&:last)
131
+ route_params = path_params || {}
132
+ args = handler_params.map do |name|
133
+ if route_params.key?(name)
134
+ route_params[name]
135
+ elsif name == :request || name == :req
136
+ req
137
+ else
138
+ res
139
+ end
140
+ end
141
+
142
+ # Execute handler
143
+ handler_result = args.empty? ? route.handler.call : route.handler.call(*args)
144
+
145
+ # Auto-detect response type
146
+ if handler_result.is_a?(Tina4::Response)
147
+ final = handler_result
148
+ elsif route.respond_to?(:template) && route.template && handler_result.is_a?(Hash)
149
+ html = Tina4::Template.render(route.template, handler_result)
150
+ res.html(html)
151
+ final = res
152
+ else
153
+ final = Tina4::Response.auto_detect(handler_result, res)
154
+ end
155
+
156
+ TestResponse.new(final.to_rack)
157
+ end
158
+ end
159
+ end
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.10.31"
4
+ VERSION = "3.10.32"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -48,6 +48,8 @@ require_relative "tina4/sql_translation"
48
48
  require_relative "tina4/response_cache"
49
49
  require_relative "tina4/html_element"
50
50
  require_relative "tina4/error_overlay"
51
+ require_relative "tina4/test_client"
52
+ require_relative "tina4/mcp"
51
53
 
52
54
  module Tina4
53
55
  # ── Lazy-loaded: database drivers ─────────────────────────────────────
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.31
4
+ version: 3.10.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
@@ -321,6 +321,7 @@ files:
321
321
  - lib/tina4/html_element.rb
322
322
  - lib/tina4/localization.rb
323
323
  - lib/tina4/log.rb
324
+ - lib/tina4/mcp.rb
324
325
  - lib/tina4/messenger.rb
325
326
  - lib/tina4/middleware.rb
326
327
  - lib/tina4/migration.rb
@@ -386,6 +387,7 @@ files:
386
387
  - lib/tina4/templates/errors/502.twig
387
388
  - lib/tina4/templates/errors/503.twig
388
389
  - lib/tina4/templates/errors/base.twig
390
+ - lib/tina4/test_client.rb
389
391
  - lib/tina4/testing.rb
390
392
  - lib/tina4/validator.rb
391
393
  - lib/tina4/version.rb