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 +4 -4
- data/lib/tina4/mcp.rb +696 -0
- data/lib/tina4/test_client.rb +159 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e75cf8a64755d61b6f6037110a61dfd2183bc46c7e77ce04aba13af145a64268
|
|
4
|
+
data.tar.gz: f6d0b3e2f5bd0133acbcad91d322ae9ab3410c62eb2847f0edff5aeef94cc4f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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.
|
|
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
|