tina4-nodejs 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.
- package/package.json +1 -1
- package/packages/core/src/index.ts +7 -0
- package/packages/core/src/mcp.test.ts +396 -0
- package/packages/core/src/mcp.ts +992 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.32",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
|
@@ -100,3 +100,10 @@ export type { ValidationError } from "./validator.js";
|
|
|
100
100
|
export type { WebSocketConnection } from "./websocketConnection.js";
|
|
101
101
|
export { RedisBackplane, NATSBackplane, createBackplane } from "./websocketBackplane.js";
|
|
102
102
|
export type { WebSocketBackplane } from "./websocketBackplane.js";
|
|
103
|
+
export {
|
|
104
|
+
McpServer, mcpTool, mcpResource, registerDevTools,
|
|
105
|
+
encodeResponse, encodeError, encodeNotification, decodeRequest,
|
|
106
|
+
schemaFromParams, isLocalhost,
|
|
107
|
+
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR,
|
|
108
|
+
} from "./mcp.js";
|
|
109
|
+
export type { JsonRpcMessage, McpToolDefinition, McpResourceDefinition, JsonSchema, McpToolParam } from "./mcp.js";
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the MCP (Model Context Protocol) implementation.
|
|
3
|
+
* Run with: npx tsx packages/core/src/mcp.test.ts
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
encodeResponse, encodeError, encodeNotification, decodeRequest,
|
|
7
|
+
McpServer, isLocalhost, schemaFromParams, registerDevTools,
|
|
8
|
+
PARSE_ERROR, METHOD_NOT_FOUND, INTERNAL_ERROR,
|
|
9
|
+
} from "./mcp.ts";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
|
|
14
|
+
let pass = 0;
|
|
15
|
+
let fail = 0;
|
|
16
|
+
|
|
17
|
+
function assert(name: string, condition: boolean, detail = "") {
|
|
18
|
+
if (condition) {
|
|
19
|
+
console.log(` \x1b[32mPASS\x1b[0m ${name}`);
|
|
20
|
+
pass++;
|
|
21
|
+
} else {
|
|
22
|
+
console.log(` \x1b[31mFAIL\x1b[0m ${name} ${detail}`);
|
|
23
|
+
fail++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertThrows(name: string, fn: () => void, match?: string) {
|
|
28
|
+
try {
|
|
29
|
+
fn();
|
|
30
|
+
console.log(` \x1b[31mFAIL\x1b[0m ${name} (no exception thrown)`);
|
|
31
|
+
fail++;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
if (match && !(e as Error).message.includes(match)) {
|
|
34
|
+
console.log(` \x1b[31mFAIL\x1b[0m ${name} (expected "${match}" in "${(e as Error).message}")`);
|
|
35
|
+
fail++;
|
|
36
|
+
} else {
|
|
37
|
+
console.log(` \x1b[32mPASS\x1b[0m ${name}`);
|
|
38
|
+
pass++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── JSON-RPC 2.0 Protocol ────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
console.log("\nJSON-RPC 2.0 Protocol");
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
const raw = encodeResponse(1, { tools: [] });
|
|
49
|
+
const msg = JSON.parse(raw);
|
|
50
|
+
assert("encodeResponse — jsonrpc", msg.jsonrpc === "2.0");
|
|
51
|
+
assert("encodeResponse — id", msg.id === 1);
|
|
52
|
+
assert("encodeResponse — result", JSON.stringify(msg.result) === JSON.stringify({ tools: [] }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
const raw = encodeError(2, METHOD_NOT_FOUND, "Not found");
|
|
57
|
+
const msg = JSON.parse(raw);
|
|
58
|
+
assert("encodeError — jsonrpc", msg.jsonrpc === "2.0");
|
|
59
|
+
assert("encodeError — id", msg.id === 2);
|
|
60
|
+
assert("encodeError — code", msg.error.code === -32601);
|
|
61
|
+
assert("encodeError — message", msg.error.message === "Not found");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
const raw = encodeError(3, INTERNAL_ERROR, "Fail", { detail: "extra" });
|
|
66
|
+
const msg = JSON.parse(raw);
|
|
67
|
+
assert("encodeError with data", msg.error.data?.detail === "extra");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
const { method, params, requestId } = decodeRequest(JSON.stringify({
|
|
72
|
+
jsonrpc: "2.0", id: 3, method: "tools/list", params: {},
|
|
73
|
+
}));
|
|
74
|
+
assert("decodeRequest — method", method === "tools/list");
|
|
75
|
+
assert("decodeRequest — params", JSON.stringify(params) === "{}");
|
|
76
|
+
assert("decodeRequest — id", requestId === 3);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
const { method, requestId } = decodeRequest({
|
|
81
|
+
jsonrpc: "2.0", method: "notifications/initialized",
|
|
82
|
+
});
|
|
83
|
+
assert("decodeRequest notification — method", method === "notifications/initialized");
|
|
84
|
+
assert("decodeRequest notification — id null", requestId === null);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
assertThrows("decodeRequest invalid JSON", () => decodeRequest("not json"), "Invalid JSON");
|
|
88
|
+
|
|
89
|
+
assertThrows("decodeRequest missing method", () => decodeRequest({ jsonrpc: "2.0", id: 1 } as any), "method");
|
|
90
|
+
|
|
91
|
+
assertThrows("decodeRequest missing jsonrpc", () => decodeRequest({ method: "test", id: 1 } as any), "jsonrpc");
|
|
92
|
+
|
|
93
|
+
// ── Notification Encoding ────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
console.log("\nNotification Encoding");
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
const raw = encodeNotification("test/event", { key: "value" });
|
|
99
|
+
const msg = JSON.parse(raw);
|
|
100
|
+
assert("encodeNotification — jsonrpc", msg.jsonrpc === "2.0");
|
|
101
|
+
assert("encodeNotification — method", msg.method === "test/event");
|
|
102
|
+
assert("encodeNotification — params", msg.params?.key === "value");
|
|
103
|
+
assert("encodeNotification — no id", msg.id === undefined);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
const raw = encodeNotification("test/simple");
|
|
108
|
+
const msg = JSON.parse(raw);
|
|
109
|
+
assert("encodeNotification without params", msg.params === undefined);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── McpServer Core ───────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
console.log("\nMcpServer Core");
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
McpServer._instances = [];
|
|
118
|
+
const server = new McpServer("/test-mcp", "Test Server", "0.1.0");
|
|
119
|
+
const resp = JSON.parse(server.handleMessage({
|
|
120
|
+
jsonrpc: "2.0", id: 1, method: "initialize",
|
|
121
|
+
params: {
|
|
122
|
+
protocolVersion: "2024-11-05", capabilities: {},
|
|
123
|
+
clientInfo: { name: "test", version: "1.0" },
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
126
|
+
assert("initialize — protocolVersion", resp.result.protocolVersion === "2024-11-05");
|
|
127
|
+
assert("initialize — serverInfo.name", resp.result.serverInfo.name === "Test Server");
|
|
128
|
+
assert("initialize — capabilities.tools", resp.result.capabilities.tools !== undefined);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
McpServer._instances = [];
|
|
133
|
+
const server = new McpServer("/test-mcp", "Test Server");
|
|
134
|
+
const resp = JSON.parse(server.handleMessage({
|
|
135
|
+
jsonrpc: "2.0", id: 2, method: "ping", params: {},
|
|
136
|
+
}));
|
|
137
|
+
assert("ping — result empty", JSON.stringify(resp.result) === "{}");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
McpServer._instances = [];
|
|
142
|
+
const server = new McpServer("/test-mcp", "Test Server");
|
|
143
|
+
const resp = JSON.parse(server.handleMessage({
|
|
144
|
+
jsonrpc: "2.0", id: 3, method: "nonexistent", params: {},
|
|
145
|
+
}));
|
|
146
|
+
assert("method not found — code", resp.error.code === -32601);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
McpServer._instances = [];
|
|
151
|
+
const server = new McpServer("/test-mcp", "Test Server");
|
|
152
|
+
const resp = server.handleMessage({
|
|
153
|
+
jsonrpc: "2.0", method: "notifications/initialized",
|
|
154
|
+
});
|
|
155
|
+
assert("notification — empty response", resp === "");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Tool Registration and Call ───────────────────────────────
|
|
159
|
+
|
|
160
|
+
console.log("\nTool Registration and Call");
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
McpServer._instances = [];
|
|
164
|
+
const server = new McpServer("/test-tools", "Tool Test");
|
|
165
|
+
server.registerTool(
|
|
166
|
+
"greet",
|
|
167
|
+
(args) => `Hello, ${args.name}!`,
|
|
168
|
+
"Greet someone",
|
|
169
|
+
schemaFromParams([{ name: "name", type: "string" }]),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const resp = JSON.parse(server.handleMessage({
|
|
173
|
+
jsonrpc: "2.0", id: 1, method: "tools/list", params: {},
|
|
174
|
+
}));
|
|
175
|
+
const tools = resp.result.tools;
|
|
176
|
+
assert("registerTool — count", tools.length === 1);
|
|
177
|
+
assert("registerTool — name", tools[0].name === "greet");
|
|
178
|
+
assert("registerTool — schema type", tools[0].inputSchema.properties.name.type === "string");
|
|
179
|
+
assert("registerTool — required", tools[0].inputSchema.required?.includes("name") === true);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
McpServer._instances = [];
|
|
184
|
+
const server = new McpServer("/test-tools", "Tool Test");
|
|
185
|
+
server.registerTool(
|
|
186
|
+
"add",
|
|
187
|
+
(args) => (args.a as number) + (args.b as number),
|
|
188
|
+
"Add two numbers",
|
|
189
|
+
schemaFromParams([{ name: "a", type: "integer" }, { name: "b", type: "integer" }]),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const resp = JSON.parse(server.handleMessage({
|
|
193
|
+
jsonrpc: "2.0", id: 2, method: "tools/call",
|
|
194
|
+
params: { name: "add", arguments: { a: 3, b: 5 } },
|
|
195
|
+
}));
|
|
196
|
+
const content = resp.result.content;
|
|
197
|
+
assert("callTool — content count", content.length === 1);
|
|
198
|
+
assert("callTool — type", content[0].type === "text");
|
|
199
|
+
assert("callTool — result contains 8", content[0].text.includes("8"));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
McpServer._instances = [];
|
|
204
|
+
const server = new McpServer("/test-tools", "Tool Test");
|
|
205
|
+
const resp = JSON.parse(server.handleMessage({
|
|
206
|
+
jsonrpc: "2.0", id: 3, method: "tools/call",
|
|
207
|
+
params: { name: "missing", arguments: {} },
|
|
208
|
+
}));
|
|
209
|
+
assert("unknown tool — error code", resp.error.code === -32603);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
{
|
|
213
|
+
McpServer._instances = [];
|
|
214
|
+
const server = new McpServer("/test-tools", "Tool Test");
|
|
215
|
+
server.registerTool("echo", (args) => args.msg as string, "Echo",
|
|
216
|
+
schemaFromParams([{ name: "msg", type: "string" }]));
|
|
217
|
+
|
|
218
|
+
const resp = JSON.parse(server.handleMessage({
|
|
219
|
+
jsonrpc: "2.0", id: 4, method: "tools/call",
|
|
220
|
+
params: { name: "echo", arguments: { msg: "hello" } },
|
|
221
|
+
}));
|
|
222
|
+
assert("tool string result", resp.result.content[0].text === "hello");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
{
|
|
226
|
+
McpServer._instances = [];
|
|
227
|
+
const server = new McpServer("/test-tools", "Tool Test");
|
|
228
|
+
server.registerTool("data", () => ({ a: 1, b: 2 }), "Return data");
|
|
229
|
+
|
|
230
|
+
const resp = JSON.parse(server.handleMessage({
|
|
231
|
+
jsonrpc: "2.0", id: 5, method: "tools/call",
|
|
232
|
+
params: { name: "data", arguments: {} },
|
|
233
|
+
}));
|
|
234
|
+
const data = JSON.parse(resp.result.content[0].text);
|
|
235
|
+
assert("tool object result — a", data.a === 1);
|
|
236
|
+
assert("tool object result — b", data.b === 2);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Schema from Params ───────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
console.log("\nSchema from Params");
|
|
242
|
+
|
|
243
|
+
{
|
|
244
|
+
const schema = schemaFromParams([
|
|
245
|
+
{ name: "name", type: "string" },
|
|
246
|
+
{ name: "count", type: "integer", default: 5 },
|
|
247
|
+
{ name: "active", type: "boolean", default: true },
|
|
248
|
+
]);
|
|
249
|
+
assert("schema — name type", schema.properties.name.type === "string");
|
|
250
|
+
assert("schema — count type", schema.properties.count.type === "integer");
|
|
251
|
+
assert("schema — count default", schema.properties.count.default === 5);
|
|
252
|
+
assert("schema — active type", schema.properties.active.type === "boolean");
|
|
253
|
+
assert("schema — required only name", JSON.stringify(schema.required) === '["name"]');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Resource Registration and Read ───────────────────────────
|
|
257
|
+
|
|
258
|
+
console.log("\nResource Registration and Read");
|
|
259
|
+
|
|
260
|
+
{
|
|
261
|
+
McpServer._instances = [];
|
|
262
|
+
const server = new McpServer("/test-resources", "Resource Test");
|
|
263
|
+
server.registerResource("app://tables", () => ["users", "products"], "Database tables");
|
|
264
|
+
|
|
265
|
+
const resp = JSON.parse(server.handleMessage({
|
|
266
|
+
jsonrpc: "2.0", id: 1, method: "resources/list", params: {},
|
|
267
|
+
}));
|
|
268
|
+
const resources = resp.result.resources;
|
|
269
|
+
assert("registerResource — count", resources.length === 1);
|
|
270
|
+
assert("registerResource — uri", resources[0].uri === "app://tables");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
McpServer._instances = [];
|
|
275
|
+
const server = new McpServer("/test-resources", "Resource Test");
|
|
276
|
+
server.registerResource("app://info", () => ({ version: "1.0", name: "Test App" }), "App info");
|
|
277
|
+
|
|
278
|
+
const resp = JSON.parse(server.handleMessage({
|
|
279
|
+
jsonrpc: "2.0", id: 2, method: "resources/read",
|
|
280
|
+
params: { uri: "app://info" },
|
|
281
|
+
}));
|
|
282
|
+
const contents = resp.result.contents;
|
|
283
|
+
assert("readResource — count", contents.length === 1);
|
|
284
|
+
const data = JSON.parse(contents[0].text);
|
|
285
|
+
assert("readResource — version", data.version === "1.0");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
{
|
|
289
|
+
McpServer._instances = [];
|
|
290
|
+
const server = new McpServer("/test-resources", "Resource Test");
|
|
291
|
+
const resp = JSON.parse(server.handleMessage({
|
|
292
|
+
jsonrpc: "2.0", id: 3, method: "resources/read",
|
|
293
|
+
params: { uri: "app://missing" },
|
|
294
|
+
}));
|
|
295
|
+
assert("unknown resource — error code", resp.error.code === -32603);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Localhost Detection ──────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
console.log("\nLocalhost Detection");
|
|
301
|
+
|
|
302
|
+
{
|
|
303
|
+
const oldHost = process.env.HOST_NAME;
|
|
304
|
+
|
|
305
|
+
process.env.HOST_NAME = "localhost:7148";
|
|
306
|
+
assert("isLocalhost — localhost", isLocalhost() === true);
|
|
307
|
+
|
|
308
|
+
process.env.HOST_NAME = "127.0.0.1:7148";
|
|
309
|
+
assert("isLocalhost — 127.0.0.1", isLocalhost() === true);
|
|
310
|
+
|
|
311
|
+
process.env.HOST_NAME = "0.0.0.0:7148";
|
|
312
|
+
assert("isLocalhost — 0.0.0.0", isLocalhost() === true);
|
|
313
|
+
|
|
314
|
+
process.env.HOST_NAME = "myserver.example.com:7148";
|
|
315
|
+
assert("isLocalhost — remote false", isLocalhost() === false);
|
|
316
|
+
|
|
317
|
+
// Restore
|
|
318
|
+
if (oldHost !== undefined) {
|
|
319
|
+
process.env.HOST_NAME = oldHost;
|
|
320
|
+
} else {
|
|
321
|
+
delete process.env.HOST_NAME;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── File Sandbox ─────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
console.log("\nFile Sandbox");
|
|
328
|
+
|
|
329
|
+
{
|
|
330
|
+
McpServer._instances = [];
|
|
331
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
332
|
+
const oldCwd = process.cwd();
|
|
333
|
+
process.chdir(tmpDir);
|
|
334
|
+
try {
|
|
335
|
+
const server = new McpServer("/test-sandbox", "Sandbox Test");
|
|
336
|
+
registerDevTools(server);
|
|
337
|
+
|
|
338
|
+
const resp = JSON.parse(server.handleMessage({
|
|
339
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
340
|
+
params: { name: "file_read", arguments: { path: "../../../etc/passwd" } },
|
|
341
|
+
}));
|
|
342
|
+
const hasError = resp.error !== undefined;
|
|
343
|
+
const errorMsg = resp.error?.message?.toLowerCase() ?? "";
|
|
344
|
+
assert(
|
|
345
|
+
"file_read sandbox — rejects path escape",
|
|
346
|
+
hasError && (errorMsg.includes("escapes") || errorMsg.includes("path")),
|
|
347
|
+
`Got: ${JSON.stringify(resp)}`,
|
|
348
|
+
);
|
|
349
|
+
} finally {
|
|
350
|
+
process.chdir(oldCwd);
|
|
351
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
{
|
|
356
|
+
McpServer._instances = [];
|
|
357
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
358
|
+
const oldCwd = process.cwd();
|
|
359
|
+
process.chdir(tmpDir);
|
|
360
|
+
try {
|
|
361
|
+
const server = new McpServer("/test-sandbox2", "Sandbox Test 2");
|
|
362
|
+
registerDevTools(server);
|
|
363
|
+
|
|
364
|
+
const resp = JSON.parse(server.handleMessage({
|
|
365
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
366
|
+
params: { name: "file_write", arguments: { path: "../../evil.txt", content: "hacked" } },
|
|
367
|
+
}));
|
|
368
|
+
assert(
|
|
369
|
+
"file_write sandbox — rejects path escape",
|
|
370
|
+
resp.error !== undefined,
|
|
371
|
+
`Got: ${JSON.stringify(resp)}`,
|
|
372
|
+
);
|
|
373
|
+
} finally {
|
|
374
|
+
process.chdir(oldCwd);
|
|
375
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Instance Registry ────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
console.log("\nInstance Registry");
|
|
382
|
+
|
|
383
|
+
{
|
|
384
|
+
McpServer._instances = [];
|
|
385
|
+
assert("instances — starts empty", McpServer._instances.length === 0);
|
|
386
|
+
new McpServer("/a", "Server A");
|
|
387
|
+
new McpServer("/b", "Server B");
|
|
388
|
+
assert("instances — two registered", McpServer._instances.length === 2);
|
|
389
|
+
McpServer._instances = [];
|
|
390
|
+
assert("instances — cleared", McpServer._instances.length === 0);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Summary ──────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
console.log(`\nMCP Tests: ${pass} passed, ${fail} failed`);
|
|
396
|
+
if (fail > 0) process.exit(1);
|
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
// Tina4 MCP Server — Model Context Protocol for AI tool integration.
|
|
2
|
+
//
|
|
3
|
+
// Built-in MCP server for dev tools + developer API for custom MCP servers.
|
|
4
|
+
//
|
|
5
|
+
// Usage (developer):
|
|
6
|
+
//
|
|
7
|
+
// import { McpServer, mcpTool, mcpResource } from "@tina4/core";
|
|
8
|
+
//
|
|
9
|
+
// const mcp = new McpServer("/my-mcp", "My App Tools");
|
|
10
|
+
//
|
|
11
|
+
// mcpTool("lookup_invoice", "Find invoice by number", mcp)(
|
|
12
|
+
// (args: { invoice_no: string }) => db.fetchOne("SELECT * FROM invoices WHERE invoice_no = ?", [args.invoice_no])
|
|
13
|
+
// );
|
|
14
|
+
//
|
|
15
|
+
// Built-in dev tools auto-register when TINA4_DEBUG=true and running on localhost.
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
|
|
21
|
+
// ── Types ─────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface JsonRpcMessage {
|
|
24
|
+
jsonrpc: "2.0";
|
|
25
|
+
id?: number | string | null;
|
|
26
|
+
method?: string;
|
|
27
|
+
params?: Record<string, unknown>;
|
|
28
|
+
result?: unknown;
|
|
29
|
+
error?: { code: number; message: string; data?: unknown };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface McpToolDefinition {
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
inputSchema: JsonSchema;
|
|
36
|
+
handler: (args: Record<string, unknown>) => unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface McpResourceDefinition {
|
|
40
|
+
uri: string;
|
|
41
|
+
name: string;
|
|
42
|
+
description: string;
|
|
43
|
+
mimeType: string;
|
|
44
|
+
handler: () => unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface JsonSchema {
|
|
48
|
+
type: string;
|
|
49
|
+
properties: Record<string, { type: string; default?: unknown }>;
|
|
50
|
+
required?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface McpToolParam {
|
|
54
|
+
name: string;
|
|
55
|
+
type: "string" | "integer" | "number" | "boolean" | "array" | "object";
|
|
56
|
+
required?: boolean;
|
|
57
|
+
default?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── JSON-RPC 2.0 Protocol ────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export const PARSE_ERROR = -32700;
|
|
63
|
+
export const INVALID_REQUEST = -32600;
|
|
64
|
+
export const METHOD_NOT_FOUND = -32601;
|
|
65
|
+
export const INVALID_PARAMS = -32602;
|
|
66
|
+
export const INTERNAL_ERROR = -32603;
|
|
67
|
+
|
|
68
|
+
export function encodeResponse(requestId: number | string | null | undefined, result: unknown): string {
|
|
69
|
+
return JSON.stringify({ jsonrpc: "2.0", id: requestId, result });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function encodeError(
|
|
73
|
+
requestId: number | string | null | undefined,
|
|
74
|
+
code: number,
|
|
75
|
+
message: string,
|
|
76
|
+
data?: unknown,
|
|
77
|
+
): string {
|
|
78
|
+
const error: { code: number; message: string; data?: unknown } = { code, message };
|
|
79
|
+
if (data !== undefined) {
|
|
80
|
+
error.data = data;
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify({ jsonrpc: "2.0", id: requestId, error });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function encodeNotification(method: string, params?: Record<string, unknown>): string {
|
|
86
|
+
const msg: Record<string, unknown> = { jsonrpc: "2.0", method };
|
|
87
|
+
if (params !== undefined) {
|
|
88
|
+
msg.params = params;
|
|
89
|
+
}
|
|
90
|
+
return JSON.stringify(msg);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function decodeRequest(data: string | Record<string, unknown>): {
|
|
94
|
+
method: string;
|
|
95
|
+
params: Record<string, unknown>;
|
|
96
|
+
requestId: number | string | null;
|
|
97
|
+
} {
|
|
98
|
+
let msg: Record<string, unknown>;
|
|
99
|
+
|
|
100
|
+
if (typeof data === "string") {
|
|
101
|
+
try {
|
|
102
|
+
msg = JSON.parse(data) as Record<string, unknown>;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
throw new Error(`Invalid JSON: ${(e as Error).message}`);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
msg = data;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof msg !== "object" || msg === null || Array.isArray(msg)) {
|
|
111
|
+
throw new Error("Message must be a JSON object");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (msg.jsonrpc !== "2.0") {
|
|
115
|
+
throw new Error("Missing or invalid jsonrpc version");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const method = msg.method;
|
|
119
|
+
if (!method || typeof method !== "string") {
|
|
120
|
+
throw new Error("Missing or invalid method");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const params = (msg.params as Record<string, unknown>) || {};
|
|
124
|
+
const requestId = (msg.id as number | string | null) ?? null;
|
|
125
|
+
|
|
126
|
+
return { method, params, requestId };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Schema extraction from parameter metadata ────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build a JSON Schema from an explicit parameter list.
|
|
133
|
+
* Since TypeScript erases types at runtime, we use explicit metadata.
|
|
134
|
+
*/
|
|
135
|
+
export function schemaFromParams(params: McpToolParam[]): JsonSchema {
|
|
136
|
+
const properties: Record<string, { type: string; default?: unknown }> = {};
|
|
137
|
+
const required: string[] = [];
|
|
138
|
+
|
|
139
|
+
for (const p of params) {
|
|
140
|
+
const prop: { type: string; default?: unknown } = { type: p.type };
|
|
141
|
+
if (p.default !== undefined) {
|
|
142
|
+
prop.default = p.default;
|
|
143
|
+
}
|
|
144
|
+
if (p.required !== false && p.default === undefined) {
|
|
145
|
+
required.push(p.name);
|
|
146
|
+
}
|
|
147
|
+
properties[p.name] = prop;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const schema: JsonSchema = { type: "object", properties };
|
|
151
|
+
if (required.length > 0) {
|
|
152
|
+
schema.required = required;
|
|
153
|
+
}
|
|
154
|
+
return schema;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Localhost detection ──────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function isLocalhost(): boolean {
|
|
160
|
+
const hostEnv = process.env.HOST_NAME || "localhost:7148";
|
|
161
|
+
const host = hostEnv.split(":")[0];
|
|
162
|
+
return ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].includes(host);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── McpServer class ──────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
export class McpServer {
|
|
168
|
+
static _instances: McpServer[] = [];
|
|
169
|
+
|
|
170
|
+
path: string;
|
|
171
|
+
name: string;
|
|
172
|
+
version: string;
|
|
173
|
+
|
|
174
|
+
private _tools: Map<string, McpToolDefinition> = new Map();
|
|
175
|
+
private _resources: Map<string, McpResourceDefinition> = new Map();
|
|
176
|
+
private _initialized = false;
|
|
177
|
+
|
|
178
|
+
constructor(mcpPath: string, name = "Tina4 MCP", version = "1.0.0") {
|
|
179
|
+
this.path = mcpPath.replace(/\/+$/, "");
|
|
180
|
+
this.name = name;
|
|
181
|
+
this.version = version;
|
|
182
|
+
McpServer._instances.push(this);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
registerTool(
|
|
186
|
+
name: string,
|
|
187
|
+
handler: (args: Record<string, unknown>) => unknown,
|
|
188
|
+
description = "",
|
|
189
|
+
schema?: JsonSchema,
|
|
190
|
+
): void {
|
|
191
|
+
const inputSchema = schema || { type: "object", properties: {} };
|
|
192
|
+
this._tools.set(name, {
|
|
193
|
+
name,
|
|
194
|
+
description,
|
|
195
|
+
inputSchema,
|
|
196
|
+
handler,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
registerResource(
|
|
201
|
+
uri: string,
|
|
202
|
+
handler: () => unknown,
|
|
203
|
+
description = "",
|
|
204
|
+
mimeType = "application/json",
|
|
205
|
+
): void {
|
|
206
|
+
this._resources.set(uri, {
|
|
207
|
+
uri,
|
|
208
|
+
name: description || uri,
|
|
209
|
+
description,
|
|
210
|
+
mimeType,
|
|
211
|
+
handler,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
handleMessage(rawData: string | Record<string, unknown>): string {
|
|
216
|
+
let method: string;
|
|
217
|
+
let params: Record<string, unknown>;
|
|
218
|
+
let requestId: number | string | null;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
({ method, params, requestId } = decodeRequest(rawData));
|
|
222
|
+
} catch (e) {
|
|
223
|
+
return encodeError(null, PARSE_ERROR, (e as Error).message);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const handlers: Record<string, (p: Record<string, unknown>) => unknown> = {
|
|
227
|
+
initialize: (p) => this._handleInitialize(p),
|
|
228
|
+
"notifications/initialized": (p) => this._handleInitialized(p),
|
|
229
|
+
"tools/list": (p) => this._handleToolsList(p),
|
|
230
|
+
"tools/call": (p) => this._handleToolsCall(p),
|
|
231
|
+
"resources/list": (p) => this._handleResourcesList(p),
|
|
232
|
+
"resources/read": (p) => this._handleResourcesRead(p),
|
|
233
|
+
ping: (p) => this._handlePing(p),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const handler = handlers[method];
|
|
237
|
+
if (!handler) {
|
|
238
|
+
return encodeError(requestId, METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const result = handler(params);
|
|
243
|
+
if (requestId === null) {
|
|
244
|
+
return ""; // Notification — no response
|
|
245
|
+
}
|
|
246
|
+
return encodeResponse(requestId, result);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return encodeError(requestId, INTERNAL_ERROR, (e as Error).message);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private _handleInitialize(_params: Record<string, unknown>): Record<string, unknown> {
|
|
253
|
+
this._initialized = true;
|
|
254
|
+
return {
|
|
255
|
+
protocolVersion: "2024-11-05",
|
|
256
|
+
capabilities: {
|
|
257
|
+
tools: { listChanged: false },
|
|
258
|
+
resources: { subscribe: false, listChanged: false },
|
|
259
|
+
},
|
|
260
|
+
serverInfo: {
|
|
261
|
+
name: this.name,
|
|
262
|
+
version: this.version,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private _handleInitialized(_params: Record<string, unknown>): void {
|
|
268
|
+
// no-op
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private _handlePing(_params: Record<string, unknown>): Record<string, unknown> {
|
|
272
|
+
return {};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private _handleToolsList(_params: Record<string, unknown>): Record<string, unknown> {
|
|
276
|
+
const tools: Record<string, unknown>[] = [];
|
|
277
|
+
for (const t of this._tools.values()) {
|
|
278
|
+
tools.push({
|
|
279
|
+
name: t.name,
|
|
280
|
+
description: t.description,
|
|
281
|
+
inputSchema: t.inputSchema,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return { tools };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private _handleToolsCall(params: Record<string, unknown>): Record<string, unknown> {
|
|
288
|
+
const toolName = params.name as string | undefined;
|
|
289
|
+
if (!toolName) {
|
|
290
|
+
throw new Error("Missing tool name");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const tool = this._tools.get(toolName);
|
|
294
|
+
if (!tool) {
|
|
295
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const args = (params.arguments as Record<string, unknown>) || {};
|
|
299
|
+
const result = tool.handler(args);
|
|
300
|
+
|
|
301
|
+
// Format result as MCP content
|
|
302
|
+
let content: { type: string; text: string }[];
|
|
303
|
+
if (typeof result === "string") {
|
|
304
|
+
content = [{ type: "text", text: result }];
|
|
305
|
+
} else if (typeof result === "object" && result !== null) {
|
|
306
|
+
content = [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
307
|
+
} else {
|
|
308
|
+
content = [{ type: "text", text: String(result) }];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { content };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private _handleResourcesList(_params: Record<string, unknown>): Record<string, unknown> {
|
|
315
|
+
const resources: Record<string, unknown>[] = [];
|
|
316
|
+
for (const r of this._resources.values()) {
|
|
317
|
+
resources.push({
|
|
318
|
+
uri: r.uri,
|
|
319
|
+
name: r.name,
|
|
320
|
+
description: r.description,
|
|
321
|
+
mimeType: r.mimeType,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return { resources };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private _handleResourcesRead(params: Record<string, unknown>): Record<string, unknown> {
|
|
328
|
+
const uri = params.uri as string | undefined;
|
|
329
|
+
if (!uri) {
|
|
330
|
+
throw new Error("Missing resource URI");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const resource = this._resources.get(uri);
|
|
334
|
+
if (!resource) {
|
|
335
|
+
throw new Error(`Unknown resource: ${uri}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const result = resource.handler();
|
|
339
|
+
|
|
340
|
+
let text: string;
|
|
341
|
+
if (typeof result === "string") {
|
|
342
|
+
text = result;
|
|
343
|
+
} else if (typeof result === "object" && result !== null) {
|
|
344
|
+
text = JSON.stringify(result, null, 2);
|
|
345
|
+
} else {
|
|
346
|
+
text = String(result);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
contents: [
|
|
351
|
+
{
|
|
352
|
+
uri,
|
|
353
|
+
mimeType: resource.mimeType,
|
|
354
|
+
text,
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Register HTTP routes for this MCP server on the Tina4 router.
|
|
362
|
+
*
|
|
363
|
+
* Registers:
|
|
364
|
+
* POST {path}/message — JSON-RPC message endpoint
|
|
365
|
+
* GET {path}/sse — SSE endpoint for streaming
|
|
366
|
+
*/
|
|
367
|
+
registerRoutes(router: {
|
|
368
|
+
post: (pattern: string, handler: (req: unknown, res: unknown) => unknown) => { noAuth: () => unknown };
|
|
369
|
+
get: (pattern: string, handler: (req: unknown, res: unknown) => unknown) => { noAuth: () => unknown };
|
|
370
|
+
}): void {
|
|
371
|
+
const server = this;
|
|
372
|
+
const msgPath = `${this.path}/message`;
|
|
373
|
+
const ssePath = `${this.path}/sse`;
|
|
374
|
+
|
|
375
|
+
router
|
|
376
|
+
.post(msgPath, (req: unknown, res: unknown) => {
|
|
377
|
+
const request = req as { body: unknown; url?: string };
|
|
378
|
+
const response = res as ((data: unknown, status?: number, contentType?: string) => unknown);
|
|
379
|
+
const body = request.body;
|
|
380
|
+
let raw: string | Record<string, unknown>;
|
|
381
|
+
if (typeof body === "object" && body !== null) {
|
|
382
|
+
raw = body as Record<string, unknown>;
|
|
383
|
+
} else {
|
|
384
|
+
raw = typeof body === "string" ? body : String(body);
|
|
385
|
+
}
|
|
386
|
+
const result = server.handleMessage(raw);
|
|
387
|
+
if (!result) {
|
|
388
|
+
return response("", 204);
|
|
389
|
+
}
|
|
390
|
+
return response(JSON.parse(result));
|
|
391
|
+
})
|
|
392
|
+
.noAuth();
|
|
393
|
+
|
|
394
|
+
router
|
|
395
|
+
.get(ssePath, (req: unknown, res: unknown) => {
|
|
396
|
+
const request = req as { url?: string; headers?: Record<string, string> };
|
|
397
|
+
const response = res as {
|
|
398
|
+
header: (name: string, value: string) => unknown;
|
|
399
|
+
send: (data: string, status?: number, contentType?: string) => unknown;
|
|
400
|
+
};
|
|
401
|
+
// Determine base URL for the endpoint
|
|
402
|
+
const reqUrl = request.url || ssePath;
|
|
403
|
+
const endpointUrl = reqUrl.replace(/\/sse$/, "/message");
|
|
404
|
+
const sseData = `event: endpoint\ndata: ${endpointUrl}\n\n`;
|
|
405
|
+
response.header("Content-Type", "text/event-stream");
|
|
406
|
+
response.header("Cache-Control", "no-cache");
|
|
407
|
+
response.header("Connection", "keep-alive");
|
|
408
|
+
return response.send(sseData, 200, "text/event-stream");
|
|
409
|
+
})
|
|
410
|
+
.noAuth();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Write/update .claude/settings.json with this MCP server config.
|
|
415
|
+
*/
|
|
416
|
+
writeClaudeConfig(port = 7148): void {
|
|
417
|
+
const configDir = path.resolve(".claude");
|
|
418
|
+
if (!fs.existsSync(configDir)) {
|
|
419
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const configFile = path.join(configDir, "settings.json");
|
|
423
|
+
let config: Record<string, unknown> = {};
|
|
424
|
+
if (fs.existsSync(configFile)) {
|
|
425
|
+
try {
|
|
426
|
+
config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
427
|
+
} catch {
|
|
428
|
+
// ignore parse errors
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
433
|
+
config.mcpServers = {};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const serverKey = this.name.toLowerCase().replace(/ /g, "-");
|
|
437
|
+
(config.mcpServers as Record<string, unknown>)[serverKey] = {
|
|
438
|
+
url: `http://localhost:${port}${this.path}/sse`,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
fs.writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── Decorator API ──────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
let _defaultServer: McpServer | null = null;
|
|
448
|
+
|
|
449
|
+
function _getDefaultServer(): McpServer {
|
|
450
|
+
if (_defaultServer === null) {
|
|
451
|
+
_defaultServer = new McpServer("/__dev/mcp", "Tina4 Dev Tools");
|
|
452
|
+
}
|
|
453
|
+
return _defaultServer;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Register a function as an MCP tool.
|
|
458
|
+
*
|
|
459
|
+
* Usage:
|
|
460
|
+
* const greet = mcpTool("greet", "Say hello", server, [
|
|
461
|
+
* { name: "name", type: "string" },
|
|
462
|
+
* ])((args) => `Hello, ${args.name}!`);
|
|
463
|
+
*
|
|
464
|
+
* Returns the original function with _mcpToolName attached.
|
|
465
|
+
*/
|
|
466
|
+
export function mcpTool(
|
|
467
|
+
name: string,
|
|
468
|
+
description = "",
|
|
469
|
+
server?: McpServer,
|
|
470
|
+
params?: McpToolParam[],
|
|
471
|
+
): <T extends (args: Record<string, unknown>) => unknown>(fn: T) => T & { _mcpToolName: string } {
|
|
472
|
+
return <T extends (args: Record<string, unknown>) => unknown>(fn: T) => {
|
|
473
|
+
const target = server || _getDefaultServer();
|
|
474
|
+
const schema = params ? schemaFromParams(params) : { type: "object" as const, properties: {} };
|
|
475
|
+
target.registerTool(name, fn, description, schema);
|
|
476
|
+
(fn as T & { _mcpToolName: string })._mcpToolName = name;
|
|
477
|
+
return fn as T & { _mcpToolName: string };
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Register a function as an MCP resource.
|
|
483
|
+
*
|
|
484
|
+
* Usage:
|
|
485
|
+
* const tables = mcpResource("app://tables", "Database tables", "application/json", server)(
|
|
486
|
+
* () => ["users", "products"]
|
|
487
|
+
* );
|
|
488
|
+
*/
|
|
489
|
+
export function mcpResource(
|
|
490
|
+
uri: string,
|
|
491
|
+
description = "",
|
|
492
|
+
mimeType = "application/json",
|
|
493
|
+
server?: McpServer,
|
|
494
|
+
): <T extends () => unknown>(fn: T) => T & { _mcpResourceUri: string } {
|
|
495
|
+
return <T extends () => unknown>(fn: T) => {
|
|
496
|
+
const target = server || _getDefaultServer();
|
|
497
|
+
target.registerResource(uri, fn, description, mimeType);
|
|
498
|
+
(fn as T & { _mcpResourceUri: string })._mcpResourceUri = uri;
|
|
499
|
+
return fn as T & { _mcpResourceUri: string };
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Built-in dev tools ───────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Resolve a path and ensure it is within the project directory.
|
|
507
|
+
*/
|
|
508
|
+
function safePath(projectRoot: string, relPath: string): string {
|
|
509
|
+
const resolved = path.resolve(projectRoot, relPath);
|
|
510
|
+
if (!resolved.startsWith(projectRoot)) {
|
|
511
|
+
throw new Error(`Path escapes project directory: ${relPath}`);
|
|
512
|
+
}
|
|
513
|
+
return resolved;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Redact sensitive environment variable values.
|
|
518
|
+
*/
|
|
519
|
+
function redactEnv(key: string, value: string): string {
|
|
520
|
+
const sensitive = ["secret", "password", "token", "key", "credential", "api_key"];
|
|
521
|
+
if (sensitive.some((s) => key.toLowerCase().includes(s))) {
|
|
522
|
+
return "***REDACTED***";
|
|
523
|
+
}
|
|
524
|
+
return value;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Register all 24 built-in dev tools on the given McpServer.
|
|
529
|
+
*/
|
|
530
|
+
export function registerDevTools(server: McpServer): void {
|
|
531
|
+
const projectRoot = path.resolve(process.cwd());
|
|
532
|
+
|
|
533
|
+
// ── Database Tools ──────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
server.registerTool(
|
|
536
|
+
"database_query",
|
|
537
|
+
(args) => {
|
|
538
|
+
try {
|
|
539
|
+
const { initDatabase } = require("@tina4/orm");
|
|
540
|
+
const db = (globalThis as any).__tina4_db;
|
|
541
|
+
if (!db) return { error: "No database connection" };
|
|
542
|
+
const params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
|
|
543
|
+
const result = db.fetch(args.sql as string, params);
|
|
544
|
+
return { records: result.records || [], count: result.count || 0 };
|
|
545
|
+
} catch (e) {
|
|
546
|
+
return { error: (e as Error).message };
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
"Execute a read-only SQL query (SELECT)",
|
|
550
|
+
schemaFromParams([
|
|
551
|
+
{ name: "sql", type: "string" },
|
|
552
|
+
{ name: "params", type: "string", default: "[]" },
|
|
553
|
+
]),
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
server.registerTool(
|
|
557
|
+
"database_execute",
|
|
558
|
+
(args) => {
|
|
559
|
+
try {
|
|
560
|
+
const db = (globalThis as any).__tina4_db;
|
|
561
|
+
if (!db) return { error: "No database connection" };
|
|
562
|
+
const params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
|
|
563
|
+
const result = db.execute(args.sql as string, params);
|
|
564
|
+
db.commit?.();
|
|
565
|
+
return { success: true, affected_rows: result?.count ?? 0 };
|
|
566
|
+
} catch (e) {
|
|
567
|
+
return { error: (e as Error).message };
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
"Execute arbitrary SQL (INSERT/UPDATE/DELETE/DDL)",
|
|
571
|
+
schemaFromParams([
|
|
572
|
+
{ name: "sql", type: "string" },
|
|
573
|
+
{ name: "params", type: "string", default: "[]" },
|
|
574
|
+
]),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
server.registerTool(
|
|
578
|
+
"database_tables",
|
|
579
|
+
(_args) => {
|
|
580
|
+
try {
|
|
581
|
+
const db = (globalThis as any).__tina4_db;
|
|
582
|
+
if (!db) return { error: "No database connection" };
|
|
583
|
+
return db.getTables?.() ?? [];
|
|
584
|
+
} catch (e) {
|
|
585
|
+
return { error: (e as Error).message };
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
"List all database tables",
|
|
589
|
+
schemaFromParams([]),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
server.registerTool(
|
|
593
|
+
"database_columns",
|
|
594
|
+
(args) => {
|
|
595
|
+
try {
|
|
596
|
+
const db = (globalThis as any).__tina4_db;
|
|
597
|
+
if (!db) return { error: "No database connection" };
|
|
598
|
+
return db.getColumns?.(args.table as string) ?? [];
|
|
599
|
+
} catch (e) {
|
|
600
|
+
return { error: (e as Error).message };
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
"Get column definitions for a table",
|
|
604
|
+
schemaFromParams([{ name: "table", type: "string" }]),
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
// ── Route Tools ─────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
server.registerTool(
|
|
610
|
+
"route_list",
|
|
611
|
+
(_args) => {
|
|
612
|
+
try {
|
|
613
|
+
const { defaultRouter } = require("@tina4/core");
|
|
614
|
+
const routes = defaultRouter?.listRoutes?.() ?? [];
|
|
615
|
+
return routes.map((r: any) => ({
|
|
616
|
+
method: r.method || "",
|
|
617
|
+
path: r.pattern || r.path || "",
|
|
618
|
+
auth_required: r.secure ?? false,
|
|
619
|
+
}));
|
|
620
|
+
} catch (e) {
|
|
621
|
+
return { error: (e as Error).message };
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
"List all registered routes",
|
|
625
|
+
schemaFromParams([]),
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
server.registerTool(
|
|
629
|
+
"route_test",
|
|
630
|
+
(args) => {
|
|
631
|
+
// Simplified: return info about what would be tested
|
|
632
|
+
return {
|
|
633
|
+
info: "Route testing requires the test client",
|
|
634
|
+
method: args.method,
|
|
635
|
+
path: args.path,
|
|
636
|
+
};
|
|
637
|
+
},
|
|
638
|
+
"Call a route and return the response",
|
|
639
|
+
schemaFromParams([
|
|
640
|
+
{ name: "method", type: "string" },
|
|
641
|
+
{ name: "path", type: "string" },
|
|
642
|
+
{ name: "body", type: "string", default: "" },
|
|
643
|
+
{ name: "headers", type: "string", default: "{}" },
|
|
644
|
+
]),
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
server.registerTool(
|
|
648
|
+
"swagger_spec",
|
|
649
|
+
(_args) => {
|
|
650
|
+
try {
|
|
651
|
+
const { generateSpec } = require("@tina4/swagger");
|
|
652
|
+
return generateSpec?.() ?? { info: "Swagger not available" };
|
|
653
|
+
} catch {
|
|
654
|
+
return { info: "Swagger package not loaded" };
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
"Return the OpenAPI 3.0.3 JSON spec",
|
|
658
|
+
schemaFromParams([]),
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
// ── Template Tools ──────────────────────────────────────────
|
|
662
|
+
|
|
663
|
+
server.registerTool(
|
|
664
|
+
"template_render",
|
|
665
|
+
(args) => {
|
|
666
|
+
try {
|
|
667
|
+
const { renderTemplate } = require("@tina4/twig");
|
|
668
|
+
const data = typeof args.data === "string" ? JSON.parse(args.data as string) : (args.data || {});
|
|
669
|
+
return renderTemplate?.(args.template as string, data) ?? "Template engine not available";
|
|
670
|
+
} catch (e) {
|
|
671
|
+
return { error: (e as Error).message };
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
"Render a template string with data",
|
|
675
|
+
schemaFromParams([
|
|
676
|
+
{ name: "template", type: "string" },
|
|
677
|
+
{ name: "data", type: "string", default: "{}" },
|
|
678
|
+
]),
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
// ── File Tools ──────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
server.registerTool(
|
|
684
|
+
"file_read",
|
|
685
|
+
(args) => {
|
|
686
|
+
const p = safePath(projectRoot, args.path as string);
|
|
687
|
+
if (!fs.existsSync(p)) return `File not found: ${args.path}`;
|
|
688
|
+
const stat = fs.statSync(p);
|
|
689
|
+
if (!stat.isFile()) return `Not a file: ${args.path}`;
|
|
690
|
+
return fs.readFileSync(p, "utf-8");
|
|
691
|
+
},
|
|
692
|
+
"Read a project file",
|
|
693
|
+
schemaFromParams([{ name: "path", type: "string" }]),
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
server.registerTool(
|
|
697
|
+
"file_write",
|
|
698
|
+
(args) => {
|
|
699
|
+
const p = safePath(projectRoot, args.path as string);
|
|
700
|
+
const dir = path.dirname(p);
|
|
701
|
+
if (!fs.existsSync(dir)) {
|
|
702
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
703
|
+
}
|
|
704
|
+
const content = args.content as string;
|
|
705
|
+
fs.writeFileSync(p, content, "utf-8");
|
|
706
|
+
const relPath = path.relative(projectRoot, p);
|
|
707
|
+
return { written: relPath, bytes: Buffer.byteLength(content, "utf-8") };
|
|
708
|
+
},
|
|
709
|
+
"Write or update a project file",
|
|
710
|
+
schemaFromParams([
|
|
711
|
+
{ name: "path", type: "string" },
|
|
712
|
+
{ name: "content", type: "string" },
|
|
713
|
+
]),
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
server.registerTool(
|
|
717
|
+
"file_list",
|
|
718
|
+
(args) => {
|
|
719
|
+
const relPath = (args.path as string) || ".";
|
|
720
|
+
const p = safePath(projectRoot, relPath);
|
|
721
|
+
if (!fs.existsSync(p)) return { error: `Directory not found: ${relPath}` };
|
|
722
|
+
const stat = fs.statSync(p);
|
|
723
|
+
if (!stat.isDirectory()) return { error: `Not a directory: ${relPath}` };
|
|
724
|
+
const entries = fs.readdirSync(p, { withFileTypes: true })
|
|
725
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
726
|
+
.map((entry) => ({
|
|
727
|
+
name: entry.name,
|
|
728
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
729
|
+
size: entry.isFile() ? fs.statSync(path.join(p, entry.name)).size : 0,
|
|
730
|
+
}));
|
|
731
|
+
return entries;
|
|
732
|
+
},
|
|
733
|
+
"List files in a directory",
|
|
734
|
+
schemaFromParams([{ name: "path", type: "string", default: "." }]),
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
server.registerTool(
|
|
738
|
+
"asset_upload",
|
|
739
|
+
(args) => {
|
|
740
|
+
const filename = args.filename as string;
|
|
741
|
+
const content = args.content as string;
|
|
742
|
+
const encoding = (args.encoding as string) || "utf-8";
|
|
743
|
+
const target = safePath(projectRoot, `src/public/${filename}`);
|
|
744
|
+
const dir = path.dirname(target);
|
|
745
|
+
if (!fs.existsSync(dir)) {
|
|
746
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
747
|
+
}
|
|
748
|
+
if (encoding === "base64") {
|
|
749
|
+
fs.writeFileSync(target, Buffer.from(content, "base64"));
|
|
750
|
+
} else {
|
|
751
|
+
fs.writeFileSync(target, content, "utf-8");
|
|
752
|
+
}
|
|
753
|
+
const relPath = path.relative(projectRoot, target);
|
|
754
|
+
return { uploaded: relPath, bytes: fs.statSync(target).size };
|
|
755
|
+
},
|
|
756
|
+
"Upload a file to src/public/",
|
|
757
|
+
schemaFromParams([
|
|
758
|
+
{ name: "filename", type: "string" },
|
|
759
|
+
{ name: "content", type: "string" },
|
|
760
|
+
{ name: "encoding", type: "string", default: "utf-8" },
|
|
761
|
+
]),
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
// ── Migration Tools ─────────────────────────────────────────
|
|
765
|
+
|
|
766
|
+
server.registerTool(
|
|
767
|
+
"migration_status",
|
|
768
|
+
(_args) => {
|
|
769
|
+
try {
|
|
770
|
+
const db = (globalThis as any).__tina4_db;
|
|
771
|
+
if (!db) return { error: "No database connection" };
|
|
772
|
+
return { info: "Migration status not yet implemented for Node.js" };
|
|
773
|
+
} catch (e) {
|
|
774
|
+
return { error: (e as Error).message };
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
"List pending and completed migrations",
|
|
778
|
+
schemaFromParams([]),
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
server.registerTool(
|
|
782
|
+
"migration_create",
|
|
783
|
+
(args) => {
|
|
784
|
+
const desc = (args.description as string).replace(/\s+/g, "_").toLowerCase();
|
|
785
|
+
const migrationsDir = path.join(projectRoot, "migrations");
|
|
786
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
787
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
788
|
+
}
|
|
789
|
+
const existing = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
|
|
790
|
+
const nextNum = String(existing.length + 1).padStart(6, "0");
|
|
791
|
+
const filename = `${nextNum}_${desc}.sql`;
|
|
792
|
+
fs.writeFileSync(path.join(migrationsDir, filename), `-- Migration: ${args.description}\n`, "utf-8");
|
|
793
|
+
return { created: filename };
|
|
794
|
+
},
|
|
795
|
+
"Create a new migration file",
|
|
796
|
+
schemaFromParams([{ name: "description", type: "string" }]),
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
server.registerTool(
|
|
800
|
+
"migration_run",
|
|
801
|
+
(_args) => {
|
|
802
|
+
try {
|
|
803
|
+
const db = (globalThis as any).__tina4_db;
|
|
804
|
+
if (!db) return { error: "No database connection" };
|
|
805
|
+
return { info: "Migration run not yet implemented for Node.js" };
|
|
806
|
+
} catch (e) {
|
|
807
|
+
return { error: (e as Error).message };
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
"Run all pending migrations",
|
|
811
|
+
schemaFromParams([]),
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
// ── Queue Tools ─────────────────────────────────────────────
|
|
815
|
+
|
|
816
|
+
server.registerTool(
|
|
817
|
+
"queue_status",
|
|
818
|
+
(args) => {
|
|
819
|
+
try {
|
|
820
|
+
const { Queue } = require("@tina4/core");
|
|
821
|
+
const topic = (args.topic as string) || "default";
|
|
822
|
+
const q = new Queue({ topic });
|
|
823
|
+
return {
|
|
824
|
+
topic,
|
|
825
|
+
pending: q.size?.("pending") ?? 0,
|
|
826
|
+
completed: q.size?.("completed") ?? 0,
|
|
827
|
+
failed: q.size?.("failed") ?? 0,
|
|
828
|
+
};
|
|
829
|
+
} catch (e) {
|
|
830
|
+
return { error: (e as Error).message };
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
"Get queue size by status",
|
|
834
|
+
schemaFromParams([{ name: "topic", type: "string", default: "default" }]),
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
// ── Session/Cache Tools ─────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
server.registerTool(
|
|
840
|
+
"session_list",
|
|
841
|
+
(_args) => {
|
|
842
|
+
const sessionDir = path.join(projectRoot, "data", "sessions");
|
|
843
|
+
if (!fs.existsSync(sessionDir)) return [];
|
|
844
|
+
const sessions: { id: string; data?: unknown; error?: string }[] = [];
|
|
845
|
+
const files = fs.readdirSync(sessionDir).filter((f) => f.endsWith(".json"));
|
|
846
|
+
for (const f of files) {
|
|
847
|
+
try {
|
|
848
|
+
const data = JSON.parse(fs.readFileSync(path.join(sessionDir, f), "utf-8"));
|
|
849
|
+
sessions.push({ id: f.replace(".json", ""), data });
|
|
850
|
+
} catch {
|
|
851
|
+
sessions.push({ id: f.replace(".json", ""), error: "corrupt" });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return sessions;
|
|
855
|
+
},
|
|
856
|
+
"List active sessions",
|
|
857
|
+
schemaFromParams([]),
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
server.registerTool(
|
|
861
|
+
"cache_stats",
|
|
862
|
+
(_args) => {
|
|
863
|
+
try {
|
|
864
|
+
const { cacheStats } = require("@tina4/core");
|
|
865
|
+
return cacheStats?.() ?? {};
|
|
866
|
+
} catch (e) {
|
|
867
|
+
return { error: (e as Error).message };
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
"Get response cache statistics",
|
|
871
|
+
schemaFromParams([]),
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
// ── ORM Tools ───────────────────────────────────────────────
|
|
875
|
+
|
|
876
|
+
server.registerTool(
|
|
877
|
+
"orm_describe",
|
|
878
|
+
(_args) => {
|
|
879
|
+
try {
|
|
880
|
+
const modelsDir = path.join(projectRoot, "src", "models");
|
|
881
|
+
if (!fs.existsSync(modelsDir)) return [];
|
|
882
|
+
const modelFiles = fs.readdirSync(modelsDir).filter((f) => f.endsWith(".ts") || f.endsWith(".js"));
|
|
883
|
+
const models: Record<string, unknown>[] = [];
|
|
884
|
+
for (const f of modelFiles) {
|
|
885
|
+
models.push({ file: f, info: "Model inspection requires runtime import" });
|
|
886
|
+
}
|
|
887
|
+
return models;
|
|
888
|
+
} catch (e) {
|
|
889
|
+
return { error: (e as Error).message };
|
|
890
|
+
}
|
|
891
|
+
},
|
|
892
|
+
"List all ORM models with fields and types",
|
|
893
|
+
schemaFromParams([]),
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
// ── Debugging Tools ─────────────────────────────────────────
|
|
897
|
+
|
|
898
|
+
server.registerTool(
|
|
899
|
+
"log_tail",
|
|
900
|
+
(args) => {
|
|
901
|
+
const lines = (args.lines as number) || 50;
|
|
902
|
+
const logFile = path.join(projectRoot, "logs", "debug.log");
|
|
903
|
+
if (!fs.existsSync(logFile)) return [];
|
|
904
|
+
const allLines = fs.readFileSync(logFile, "utf-8").split("\n");
|
|
905
|
+
return allLines.slice(-lines);
|
|
906
|
+
},
|
|
907
|
+
"Read recent log entries",
|
|
908
|
+
schemaFromParams([{ name: "lines", type: "integer", default: 50 }]),
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
server.registerTool(
|
|
912
|
+
"error_log",
|
|
913
|
+
(args) => {
|
|
914
|
+
try {
|
|
915
|
+
const { DevAdmin } = require("@tina4/core");
|
|
916
|
+
const tracker = DevAdmin?.errorTracker;
|
|
917
|
+
if (tracker?.get) {
|
|
918
|
+
return tracker.get(args.limit || 20);
|
|
919
|
+
}
|
|
920
|
+
return [];
|
|
921
|
+
} catch {
|
|
922
|
+
return [];
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
"Recent errors and exceptions",
|
|
926
|
+
schemaFromParams([{ name: "limit", type: "integer", default: 20 }]),
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
server.registerTool(
|
|
930
|
+
"env_list",
|
|
931
|
+
(_args) => {
|
|
932
|
+
const result: Record<string, string> = {};
|
|
933
|
+
const sorted = Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b));
|
|
934
|
+
for (const [k, v] of sorted) {
|
|
935
|
+
if (v !== undefined) {
|
|
936
|
+
result[k] = redactEnv(k, v);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return result;
|
|
940
|
+
},
|
|
941
|
+
"List environment variables (secrets redacted)",
|
|
942
|
+
schemaFromParams([]),
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
// ── Data Tools ──────────────────────────────────────────────
|
|
946
|
+
|
|
947
|
+
server.registerTool(
|
|
948
|
+
"seed_table",
|
|
949
|
+
(args) => {
|
|
950
|
+
try {
|
|
951
|
+
const { seedTable } = require("@tina4/orm");
|
|
952
|
+
const db = (globalThis as any).__tina4_db;
|
|
953
|
+
if (!db) return { error: "No database connection" };
|
|
954
|
+
const count = (args.count as number) || 10;
|
|
955
|
+
const inserted = seedTable?.(db, args.table as string, count) ?? 0;
|
|
956
|
+
return { table: args.table, inserted };
|
|
957
|
+
} catch (e) {
|
|
958
|
+
return { error: (e as Error).message };
|
|
959
|
+
}
|
|
960
|
+
},
|
|
961
|
+
"Seed a table with fake data",
|
|
962
|
+
schemaFromParams([
|
|
963
|
+
{ name: "table", type: "string" },
|
|
964
|
+
{ name: "count", type: "integer", default: 10 },
|
|
965
|
+
]),
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
// ── System Tools ────────────────────────────────────────────
|
|
969
|
+
|
|
970
|
+
server.registerTool(
|
|
971
|
+
"system_info",
|
|
972
|
+
(_args) => {
|
|
973
|
+
let version = "unknown";
|
|
974
|
+
try {
|
|
975
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8"));
|
|
976
|
+
version = pkg.version || "unknown";
|
|
977
|
+
} catch {
|
|
978
|
+
// ignore
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
framework: "tina4-nodejs",
|
|
982
|
+
version,
|
|
983
|
+
node: process.version,
|
|
984
|
+
platform: `${os.type()} ${os.release()} ${os.arch()}`,
|
|
985
|
+
cwd: projectRoot,
|
|
986
|
+
debug: process.env.TINA4_DEBUG || "false",
|
|
987
|
+
};
|
|
988
|
+
},
|
|
989
|
+
"Framework version, Node.js version, project info",
|
|
990
|
+
schemaFromParams([]),
|
|
991
|
+
);
|
|
992
|
+
}
|