tina4ruby 3.11.15 → 3.11.16

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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +1289 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -124
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -116
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2087 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +871 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/plan.rb +471 -0
  63. data/lib/tina4/project_index.rb +366 -0
  64. data/lib/tina4/public/css/tina4.css +2463 -2463
  65. data/lib/tina4/public/css/tina4.min.css +1 -1
  66. data/lib/tina4/public/images/logo.svg +5 -5
  67. data/lib/tina4/public/js/frond.min.js +2 -2
  68. data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
  69. data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
  70. data/lib/tina4/public/js/tina4.min.js +92 -92
  71. data/lib/tina4/public/js/tina4js.min.js +48 -48
  72. data/lib/tina4/public/swagger/index.html +90 -90
  73. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  74. data/lib/tina4/query_builder.rb +380 -380
  75. data/lib/tina4/queue.rb +366 -366
  76. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  77. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  78. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  79. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  80. data/lib/tina4/rack_app.rb +817 -817
  81. data/lib/tina4/rate_limiter.rb +130 -130
  82. data/lib/tina4/request.rb +268 -268
  83. data/lib/tina4/response.rb +346 -346
  84. data/lib/tina4/response_cache.rb +551 -551
  85. data/lib/tina4/router.rb +406 -406
  86. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  87. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  88. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  89. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  90. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  91. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  92. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  93. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  94. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  95. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  96. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  97. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  98. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  99. data/lib/tina4/scss/tina4css/base.scss +1 -1
  100. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  101. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  102. data/lib/tina4/scss_compiler.rb +178 -178
  103. data/lib/tina4/seeder.rb +567 -567
  104. data/lib/tina4/service_runner.rb +303 -303
  105. data/lib/tina4/session.rb +297 -297
  106. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  107. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  108. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  109. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  110. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  111. data/lib/tina4/shutdown.rb +84 -84
  112. data/lib/tina4/sql_translation.rb +158 -158
  113. data/lib/tina4/swagger.rb +124 -124
  114. data/lib/tina4/template.rb +894 -894
  115. data/lib/tina4/templates/base.twig +26 -26
  116. data/lib/tina4/templates/errors/302.twig +14 -14
  117. data/lib/tina4/templates/errors/401.twig +9 -9
  118. data/lib/tina4/templates/errors/403.twig +29 -29
  119. data/lib/tina4/templates/errors/404.twig +29 -29
  120. data/lib/tina4/templates/errors/500.twig +38 -38
  121. data/lib/tina4/templates/errors/502.twig +9 -9
  122. data/lib/tina4/templates/errors/503.twig +12 -12
  123. data/lib/tina4/templates/errors/base.twig +37 -37
  124. data/lib/tina4/test_client.rb +159 -159
  125. data/lib/tina4/testing.rb +340 -340
  126. data/lib/tina4/validator.rb +174 -174
  127. data/lib/tina4/version.rb +1 -1
  128. data/lib/tina4/webserver.rb +312 -312
  129. data/lib/tina4/websocket.rb +343 -343
  130. data/lib/tina4/websocket_backplane.rb +190 -190
  131. data/lib/tina4/wsdl.rb +564 -564
  132. data/lib/tina4.rb +460 -458
  133. data/lib/tina4ruby.rb +4 -4
  134. metadata +5 -3
data/lib/tina4/mcp.rb CHANGED
@@ -1,696 +1,871 @@
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(router = nil)
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
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(router = nil)
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
+ # ── File patch ────────────────────────────────────
684
+ server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
685
+ p = safe_path.call(path)
686
+ return { "error" => "File not found: #{path}" } unless File.file?(p)
687
+ original = File.read(p, encoding: "utf-8")
688
+ occurrences = original.scan(old_string).size
689
+ return { "error" => "old_string not found in #{path}" } if occurrences.zero?
690
+ if occurrences != count.to_i
691
+ return { "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." }
692
+ end
693
+ updated = original.sub(old_string, new_string)
694
+ # Ruby String#sub replaces first; if count > 1, do N replacements
695
+ if count.to_i > 1
696
+ updated = original.dup
697
+ count.to_i.times { updated.sub!(old_string, new_string) }
698
+ end
699
+ File.write(p, updated, encoding: "utf-8")
700
+ rel = p.sub("#{project_root}/", "")
701
+ Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
702
+ { "patched" => rel, "replacements" => count.to_i, "bytes" => updated.bytesize }
703
+ }, "Targeted edit: replace old_string with new_string in a file")
704
+
705
+ # ── Docs tools ────────────────────────────────────
706
+ framework_doc_paths = lambda do
707
+ gem_root = File.expand_path("..", File.dirname(__FILE__))
708
+ candidates = [
709
+ File.join(gem_root, "..", "CLAUDE.md"),
710
+ File.join(gem_root, "..", "AGENTS.md"),
711
+ File.join(gem_root, "..", "CONVENTIONS.md"),
712
+ File.join(gem_root, "..", "README.md"),
713
+ File.join(Dir.pwd, "CLAUDE.md")
714
+ ]
715
+ candidates.map { |p| File.expand_path(p) }.uniq.select { |p| File.file?(p) }
716
+ end
717
+
718
+ server.register_tool("docs_list", lambda {
719
+ framework_doc_paths.call.map { |p| { "name" => File.basename(p), "bytes" => File.size(p) } }
720
+ }, "List framework documentation files")
721
+
722
+ server.register_tool("docs_search", lambda { |query:, limit: 5, context_lines: 4|
723
+ return { "error" => "query must be at least 2 characters" } if query.to_s.length < 2
724
+ needle = query.to_s.downcase
725
+ hits = []
726
+ framework_doc_paths.call.each do |p|
727
+ begin
728
+ lines = File.read(p, encoding: "utf-8", invalid: :replace, undef: :replace).split("\n")
729
+ rescue StandardError
730
+ next
731
+ end
732
+ lines.each_with_index do |line, i|
733
+ next unless line.downcase.include?(needle)
734
+ start_i = [0, i - context_lines.to_i].max
735
+ end_i = [lines.size, i + context_lines.to_i + 1].min
736
+ score = 1
737
+ score += 1 if line.include?(query.to_s)
738
+ score += 2 if line.lstrip.start_with?("#")
739
+ hits << {
740
+ "file" => File.basename(p),
741
+ "line" => i + 1,
742
+ "score" => score,
743
+ "snippet" => lines[start_i...end_i].join("\n")
744
+ }
745
+ end
746
+ end
747
+ hits.sort_by! { |h| -h["score"] }
748
+ hits.first([1, limit.to_i].max)
749
+ }, "Search Tina4 framework docs for a query string")
750
+
751
+ server.register_tool("docs_section", lambda { |file:, heading:|
752
+ match = framework_doc_paths.call.find { |p| File.basename(p) == file }
753
+ return { "error" => "Unknown doc file: #{file}. Try docs_list() first." } unless match
754
+ text = File.read(match, encoding: "utf-8", invalid: :replace, undef: :replace)
755
+ lines = text.split("\n")
756
+ heading_lc = heading.to_s.downcase.strip
757
+ start_i = -1
758
+ start_level = 0
759
+ lines.each_with_index do |line, i|
760
+ stripped = line.lstrip
761
+ next unless stripped.start_with?("#")
762
+ level = stripped.length - stripped.sub(/\A#+/, "").length
763
+ title = stripped[level..].to_s.strip.downcase
764
+ if title.include?(heading_lc)
765
+ start_i = i
766
+ start_level = level
767
+ break
768
+ end
769
+ end
770
+ return { "error" => "Heading '#{heading}' not found in #{file}" } if start_i < 0
771
+ end_i = lines.size
772
+ (start_i + 1).upto(lines.size - 1) do |j|
773
+ stripped = lines[j].lstrip
774
+ next unless stripped.start_with?("#")
775
+ level = stripped.length - stripped.sub(/\A#+/, "").length
776
+ if level <= start_level
777
+ end_i = j
778
+ break
779
+ end
780
+ end
781
+ { "file" => file, "heading" => lines[start_i].strip, "body" => lines[start_i...end_i].join("\n") }
782
+ }, "Return a full markdown section from a framework doc file")
783
+
784
+ # ── Git / deps / project ──────────────────────────
785
+ server.register_tool("git_status", lambda {
786
+ Tina4::DevAdmin.send(:git_status_payload)
787
+ }, "Show git branch, modified/untracked files, recent commits")
788
+
789
+ server.register_tool("deps_list", lambda {
790
+ gemfile = File.join(Dir.pwd, "Gemfile")
791
+ return { "error" => "No Gemfile at project root" } unless File.file?(gemfile)
792
+ deps = File.read(gemfile).scan(/^\s*gem\s+["']([^"']+)["']/).flatten
793
+ { "name" => File.basename(Dir.pwd), "dependencies" => deps }
794
+ }, "List this project's declared Ruby dependencies")
795
+
796
+ server.register_tool("project_overview", lambda {
797
+ { "system" => { "framework" => "tina4-ruby", "version" => (defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"), "ruby" => RUBY_DESCRIPTION, "cwd" => project_root } }
798
+ }, "One-shot snapshot: system + project info")
799
+
800
+ # ── Project index ─────────────────────────────────
801
+ server.register_tool("index_rebuild", lambda {
802
+ Tina4::ProjectIndex.refresh
803
+ }, "Refresh the persistent project index (lazy, mtime-based)")
804
+
805
+ server.register_tool("index_search", lambda { |query:, limit: 20|
806
+ Tina4::ProjectIndex.search(query, limit.to_i)
807
+ }, "Find files by path, symbol, route, or summary")
808
+
809
+ server.register_tool("index_file", lambda { |path:|
810
+ Tina4::ProjectIndex.file_entry(path)
811
+ }, "Full index entry for one file")
812
+
813
+ server.register_tool("index_overview", lambda {
814
+ Tina4::ProjectIndex.overview
815
+ }, "Project shape: files by language, routes, models, recent edits")
816
+
817
+ # ── Plan management ───────────────────────────────
818
+ server.register_tool("plan_current", lambda {
819
+ Tina4::Plan.current
820
+ }, "The active plan: title, steps (done/not), next step, progress")
821
+
822
+ server.register_tool("plan_list", lambda {
823
+ Tina4::Plan.list_plans
824
+ }, "All plans in plan/ with progress and which one is active")
825
+
826
+ server.register_tool("plan_create", lambda { |title:, goal: "", steps: nil, make_current: true|
827
+ Tina4::Plan.create(title, goal: goal, steps: steps, make_current: make_current)
828
+ }, "Create a new markdown plan in plan/ and make it active")
829
+
830
+ server.register_tool("plan_switch_to", lambda { |name:|
831
+ Tina4::Plan.set_current(name)
832
+ }, "Make a different plan the active one")
833
+
834
+ server.register_tool("plan_complete_step", lambda { |index:|
835
+ Tina4::Plan.complete_step(index.to_i)
836
+ }, "Tick a step as done (call the moment the step finishes)")
837
+
838
+ server.register_tool("plan_add_step", lambda { |text:|
839
+ Tina4::Plan.add_step(text)
840
+ }, "Append a new unchecked step to the current plan")
841
+
842
+ server.register_tool("plan_note", lambda { |text:|
843
+ Tina4::Plan.append_note(text)
844
+ }, "Append a timestamped note/breadcrumb to the current plan")
845
+
846
+ server.register_tool("plan_archive", lambda { |name: ""|
847
+ Tina4::Plan.archive(name)
848
+ }, "Move a finished plan to plan/done/")
849
+
850
+ server.register_tool("plan_read", lambda { |name:|
851
+ Tina4::Plan.read(name)
852
+ }, "Full structured view of any plan by filename")
853
+
854
+ server.register_tool("plan_flesh", lambda { |name: "", prompt: ""|
855
+ Tina4::Plan.flesh(name, prompt)
856
+ }, "Auto-generate concrete build steps via AI and append them to an existing plan")
857
+
858
+ # ── System Tools ──────────────────────────────────
859
+ server.register_tool("system_info", lambda {
860
+ {
861
+ "framework" => "tina4-ruby",
862
+ "version" => (defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"),
863
+ "ruby" => RUBY_DESCRIPTION,
864
+ "platform" => RUBY_PLATFORM,
865
+ "cwd" => project_root,
866
+ "debug" => ENV.fetch("TINA4_DEBUG", "false")
867
+ }
868
+ }, "Framework version, Ruby version, project info")
869
+ end
870
+ end
871
+ end