tina4-nodejs 3.13.33 → 3.13.35

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/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.33)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.35)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
@@ -946,7 +946,7 @@ The `tina4` Rust CLI is the sole file watcher for the Tina4 stack — there is n
946
946
 
947
947
  No configuration needed — set `TINA4_DEBUG=true` to enable. If you're running without the Rust CLI (e.g. Docker), there is no automatic reload; the production path is unaffected.
948
948
 
949
- **AI dual-port mode:** when `TINA4_DEBUG=true` and `TINA4_NO_AI_PORT` is unset, the main port suppresses reload/toolbar injection (so AI tools never trigger a refresh) and a second server on `port+1000` provides the normal hot-reload experience for browser testing.
949
+ **AI dual-port mode:** when `TINA4_DEBUG=true` and `TINA4_NO_AI_PORT` is unset, the **main port** provides the normal hot-reload experience (dev toolbar + `/__dev_reload` injected) for the human dev, and a second server on `port+1000` is the **stable AI port** — it suppresses reload/toolbar injection (and returns 404 for `/__dev_reload`) so an AI tool can drive it without its own edits triggering a refresh. The `tina4` client posts `/__dev/api/reload` to the **main port**. Matches Python (master), PHP, and Ruby.
950
950
 
951
951
  ## Conventions You Must Follow
952
952
 
@@ -1133,7 +1133,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1133
1133
  - **QueryBuilder** with NoSQL/MongoDB support (`toMongo()`)
1134
1134
  - **WebSocket backplane** (Redis pub/sub) for horizontal scaling
1135
1135
  - **SameSite=Lax** default on session cookies (`TINA4_SESSION_SAMESITE`)
1136
- - **`tina4 init`** generates Dockerfile and .dockerignore
1136
+ - **`tina4 deploy docker`** generates Dockerfile and .dockerignore
1137
1137
  - **Gallery**: 7 interactive examples with Try It deploy at `/_dev/`
1138
1138
  - **SSE/Streaming**: `response.stream()` for Server-Sent Events — pass an async generator, framework handles chunked transfer encoding and keep-alive
1139
1139
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.33",
6
+ "version": "3.13.35",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -63,11 +63,11 @@ export async function initProject(name: string): Promise<void> {
63
63
  private: true,
64
64
  type: "module",
65
65
  scripts: {
66
- dev: "tina4 serve",
67
- serve: "tina4 serve",
66
+ dev: "npx tina4nodejs serve",
67
+ serve: "npx tina4nodejs serve",
68
68
  },
69
69
  dependencies: {
70
- "tina4-nodejs": "^0.0.1",
70
+ "tina4-nodejs": "^3.0.0",
71
71
  },
72
72
  devDependencies: {
73
73
  typescript: "^5.7.0",
@@ -19,6 +19,7 @@ import { DevMailbox } from "./devMailbox.js";
19
19
  import { isTruthy } from "./dotenv.js";
20
20
  import { quickMetrics, fullAnalysis, fileDetail } from "./metrics.js";
21
21
  import { registerFeedbackRoutes } from "./feedback.js";
22
+ import { getDefaultDevServer } from "./mcp.js";
22
23
 
23
24
  const cpuCount = osCpus().length;
24
25
 
@@ -560,9 +561,18 @@ export class DevAdmin {
560
561
  { method: "POST", pattern: "/__dev/api/deps/install", handler: handleDepsInstall },
561
562
  // Git status
562
563
  { method: "GET", pattern: "/__dev/api/git/status", handler: handleGitStatus },
563
- // MCP tool introspection over the built-in MCP server
564
+ // MCP tool introspection over the built-in MCP server (browser dev-admin REST shim)
564
565
  { method: "GET", pattern: "/__dev/api/mcp/tools", handler: handleMcpTools },
565
566
  { method: "POST", pattern: "/__dev/api/mcp/call", handler: handleMcpCall },
567
+ // MCP JSON-RPC + SSE endpoints that REAL MCP clients (Claude Code/Desktop)
568
+ // speak. POST /__dev/mcp[/message] -> JSON-RPC handleMessage; GET
569
+ // /__dev/mcp/sse -> SSE handshake announcing the message endpoint. Mounted
570
+ // through the same dispatch as the REST shim above and gated by the same
571
+ // /__dev public-route rule. Mirrors the Python v3 fix (POST /__dev/mcp +
572
+ // /__dev/mcp/message, GET /__dev/mcp/sse).
573
+ { method: "POST", pattern: "/__dev/mcp", handler: handleMcpMessage },
574
+ { method: "POST", pattern: "/__dev/mcp/message", handler: handleMcpMessage },
575
+ { method: "GET", pattern: "/__dev/mcp/sse", handler: handleMcpSse },
566
576
  // Scaffolding
567
577
  { method: "GET", pattern: "/__dev/api/scaffold", handler: handleScaffoldList },
568
578
  { method: "POST", pattern: "/__dev/api/scaffold/run", handler: handleScaffoldRun },
@@ -599,6 +609,13 @@ export class DevAdmin {
599
609
  handler: route.handler,
600
610
  });
601
611
  }
612
+
613
+ // Ensure the default /__dev/mcp MCP server exists with its dev tools
614
+ // registered. This is the single shared instance behind both the REST shim
615
+ // and the JSON-RPC + SSE endpoints registered above. Doing it here (gated by
616
+ // the same TINA4_DEBUG check that gates DevAdmin.register) means tools/list
617
+ // and the REST shim return tools immediately, before any first call.
618
+ getDefaultDevServer();
602
619
  }
603
620
 
604
621
  /**
@@ -1931,7 +1948,11 @@ const handleGitStatus: RouteHandler = async (_req, res) => {
1931
1948
 
1932
1949
  const handleMcpTools: RouteHandler = async (_req, res) => {
1933
1950
  try {
1934
- const { McpServer } = await import("./mcp.js");
1951
+ // Ensure the default /__dev/mcp server exists with its dev tools registered,
1952
+ // then enumerate every registered MCP server instance (app-defined servers
1953
+ // register themselves on construction too).
1954
+ const { McpServer, getDefaultDevServer } = await import("./mcp.js");
1955
+ getDefaultDevServer();
1935
1956
  const instances = (McpServer as unknown as { _instances: Array<any> })._instances || [];
1936
1957
  const tools: Array<{ server: string; name: string; description: string; inputSchema: unknown }> = [];
1937
1958
  for (const s of instances) {
@@ -1950,7 +1971,8 @@ const handleMcpCall: RouteHandler = async (req, res) => {
1950
1971
  const name = (body.name as string) || "";
1951
1972
  const args = (body.arguments as Record<string, unknown>) || {};
1952
1973
  try {
1953
- const { McpServer } = await import("./mcp.js");
1974
+ const { McpServer, getDefaultDevServer } = await import("./mcp.js");
1975
+ getDefaultDevServer();
1954
1976
  const instances = (McpServer as unknown as { _instances: Array<any> })._instances || [];
1955
1977
  for (const s of instances) {
1956
1978
  const tool = ((s as any)._tools as Map<string, any>).get(name);
@@ -1966,6 +1988,59 @@ const handleMcpCall: RouteHandler = async (req, res) => {
1966
1988
  }
1967
1989
  };
1968
1990
 
1991
+ /**
1992
+ * JSON-RPC message endpoint for real MCP clients.
1993
+ *
1994
+ * Mounted at POST /__dev/mcp and POST /__dev/mcp/message. Forwards the request
1995
+ * body to the default dev MCP server's handleMessage() and returns the JSON-RPC
1996
+ * response. Notifications / id-less requests yield an empty 204. Mirrors the
1997
+ * Python v3 fix. The /__dev path is always public (auth-bypassed), so MCP
1998
+ * clients connect without a token.
1999
+ */
2000
+ const handleMcpMessage: RouteHandler = async (req, res) => {
2001
+ try {
2002
+ const { getDefaultDevServer } = await import("./mcp.js");
2003
+ const server = getDefaultDevServer();
2004
+ const body = req.body;
2005
+ let raw: string | Record<string, unknown>;
2006
+ if (typeof body === "object" && body !== null) {
2007
+ raw = body as Record<string, unknown>;
2008
+ } else {
2009
+ raw = typeof body === "string" ? body : String(body ?? "");
2010
+ }
2011
+ const result = await server.handleMessage(raw);
2012
+ if (!result) {
2013
+ // Notification / no id — nothing to return.
2014
+ res.send("", 204);
2015
+ return;
2016
+ }
2017
+ res.json(JSON.parse(result));
2018
+ } catch (e) {
2019
+ res.json({ error: (e as Error).message }, 500);
2020
+ }
2021
+ };
2022
+
2023
+ /**
2024
+ * SSE handshake endpoint for real MCP clients.
2025
+ *
2026
+ * Mounted at GET /__dev/mcp/sse. Announces the JSON-RPC message endpoint via an
2027
+ * `endpoint` event, exactly like the canonical McpServer.registerRoutes() and
2028
+ * the Python v3 fix. Content-Type text/event-stream, status 200.
2029
+ */
2030
+ const handleMcpSse: RouteHandler = async (req, res) => {
2031
+ // req.path is the path only (no query); turn /__dev/mcp/sse into the message
2032
+ // endpoint /__dev/mcp/message that the client should POST to.
2033
+ const reqPath = req.path || "/__dev/mcp/sse";
2034
+ const endpointUrl = reqPath.replace(/\/sse$/, "/message");
2035
+ const sseData = `event: endpoint\ndata: ${endpointUrl}\n\n`;
2036
+ res.raw.writeHead(200, {
2037
+ "Content-Type": "text/event-stream",
2038
+ "Cache-Control": "no-cache",
2039
+ Connection: "keep-alive",
2040
+ });
2041
+ res.raw.end(sseData);
2042
+ };
2043
+
1969
2044
  const handleScaffoldList: RouteHandler = (_req, res) => {
1970
2045
  res.json({
1971
2046
  scaffolds: [
@@ -121,7 +121,7 @@ export type { WebSocketConnection } from "./websocketConnection.js";
121
121
  export { RedisBackplane, NATSBackplane, createBackplane } from "./websocketBackplane.js";
122
122
  export type { WebSocketBackplane } from "./websocketBackplane.js";
123
123
  export {
124
- McpServer, mcpTool, mcpResource, registerDevTools,
124
+ McpServer, mcpTool, mcpResource, registerDevTools, getDefaultDevServer,
125
125
  encodeResponse, encodeError, encodeNotification, decodeRequest,
126
126
  schemaFromParams, isLocalhost, mcpEnabled, mcpPort,
127
127
  PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR,
@@ -116,7 +116,7 @@ console.log("\nMcpServer Core");
116
116
  {
117
117
  McpServer._instances = [];
118
118
  const server = new McpServer("/test-mcp", "Test Server", "0.1.0");
119
- const resp = JSON.parse(server.handleMessage({
119
+ const resp = JSON.parse(await server.handleMessage({
120
120
  jsonrpc: "2.0", id: 1, method: "initialize",
121
121
  params: {
122
122
  protocolVersion: "2024-11-05", capabilities: {},
@@ -131,7 +131,7 @@ console.log("\nMcpServer Core");
131
131
  {
132
132
  McpServer._instances = [];
133
133
  const server = new McpServer("/test-mcp", "Test Server");
134
- const resp = JSON.parse(server.handleMessage({
134
+ const resp = JSON.parse(await server.handleMessage({
135
135
  jsonrpc: "2.0", id: 2, method: "ping", params: {},
136
136
  }));
137
137
  assert("ping — result empty", JSON.stringify(resp.result) === "{}");
@@ -140,7 +140,7 @@ console.log("\nMcpServer Core");
140
140
  {
141
141
  McpServer._instances = [];
142
142
  const server = new McpServer("/test-mcp", "Test Server");
143
- const resp = JSON.parse(server.handleMessage({
143
+ const resp = JSON.parse(await server.handleMessage({
144
144
  jsonrpc: "2.0", id: 3, method: "nonexistent", params: {},
145
145
  }));
146
146
  assert("method not found — code", resp.error.code === -32601);
@@ -149,7 +149,7 @@ console.log("\nMcpServer Core");
149
149
  {
150
150
  McpServer._instances = [];
151
151
  const server = new McpServer("/test-mcp", "Test Server");
152
- const resp = server.handleMessage({
152
+ const resp = await server.handleMessage({
153
153
  jsonrpc: "2.0", method: "notifications/initialized",
154
154
  });
155
155
  assert("notification — empty response", resp === "");
@@ -169,7 +169,7 @@ console.log("\nTool Registration and Call");
169
169
  schemaFromParams([{ name: "name", type: "string" }]),
170
170
  );
171
171
 
172
- const resp = JSON.parse(server.handleMessage({
172
+ const resp = JSON.parse(await server.handleMessage({
173
173
  jsonrpc: "2.0", id: 1, method: "tools/list", params: {},
174
174
  }));
175
175
  const tools = resp.result.tools;
@@ -189,7 +189,7 @@ console.log("\nTool Registration and Call");
189
189
  schemaFromParams([{ name: "a", type: "integer" }, { name: "b", type: "integer" }]),
190
190
  );
191
191
 
192
- const resp = JSON.parse(server.handleMessage({
192
+ const resp = JSON.parse(await server.handleMessage({
193
193
  jsonrpc: "2.0", id: 2, method: "tools/call",
194
194
  params: { name: "add", arguments: { a: 3, b: 5 } },
195
195
  }));
@@ -202,7 +202,7 @@ console.log("\nTool Registration and Call");
202
202
  {
203
203
  McpServer._instances = [];
204
204
  const server = new McpServer("/test-tools", "Tool Test");
205
- const resp = JSON.parse(server.handleMessage({
205
+ const resp = JSON.parse(await server.handleMessage({
206
206
  jsonrpc: "2.0", id: 3, method: "tools/call",
207
207
  params: { name: "missing", arguments: {} },
208
208
  }));
@@ -215,7 +215,7 @@ console.log("\nTool Registration and Call");
215
215
  server.registerTool("echo", (args) => args.msg as string, "Echo",
216
216
  schemaFromParams([{ name: "msg", type: "string" }]));
217
217
 
218
- const resp = JSON.parse(server.handleMessage({
218
+ const resp = JSON.parse(await server.handleMessage({
219
219
  jsonrpc: "2.0", id: 4, method: "tools/call",
220
220
  params: { name: "echo", arguments: { msg: "hello" } },
221
221
  }));
@@ -227,7 +227,7 @@ console.log("\nTool Registration and Call");
227
227
  const server = new McpServer("/test-tools", "Tool Test");
228
228
  server.registerTool("data", () => ({ a: 1, b: 2 }), "Return data");
229
229
 
230
- const resp = JSON.parse(server.handleMessage({
230
+ const resp = JSON.parse(await server.handleMessage({
231
231
  jsonrpc: "2.0", id: 5, method: "tools/call",
232
232
  params: { name: "data", arguments: {} },
233
233
  }));
@@ -262,7 +262,7 @@ console.log("\nResource Registration and Read");
262
262
  const server = new McpServer("/test-resources", "Resource Test");
263
263
  server.registerResource("app://tables", () => ["users", "products"], "Database tables");
264
264
 
265
- const resp = JSON.parse(server.handleMessage({
265
+ const resp = JSON.parse(await server.handleMessage({
266
266
  jsonrpc: "2.0", id: 1, method: "resources/list", params: {},
267
267
  }));
268
268
  const resources = resp.result.resources;
@@ -275,7 +275,7 @@ console.log("\nResource Registration and Read");
275
275
  const server = new McpServer("/test-resources", "Resource Test");
276
276
  server.registerResource("app://info", () => ({ version: "1.0", name: "Test App" }), "App info");
277
277
 
278
- const resp = JSON.parse(server.handleMessage({
278
+ const resp = JSON.parse(await server.handleMessage({
279
279
  jsonrpc: "2.0", id: 2, method: "resources/read",
280
280
  params: { uri: "app://info" },
281
281
  }));
@@ -288,7 +288,7 @@ console.log("\nResource Registration and Read");
288
288
  {
289
289
  McpServer._instances = [];
290
290
  const server = new McpServer("/test-resources", "Resource Test");
291
- const resp = JSON.parse(server.handleMessage({
291
+ const resp = JSON.parse(await server.handleMessage({
292
292
  jsonrpc: "2.0", id: 3, method: "resources/read",
293
293
  params: { uri: "app://missing" },
294
294
  }));
@@ -335,7 +335,7 @@ console.log("\nFile Sandbox");
335
335
  const server = new McpServer("/test-sandbox", "Sandbox Test");
336
336
  registerDevTools(server);
337
337
 
338
- const resp = JSON.parse(server.handleMessage({
338
+ const resp = JSON.parse(await server.handleMessage({
339
339
  jsonrpc: "2.0", id: 1, method: "tools/call",
340
340
  params: { name: "file_read", arguments: { path: "../../../etc/passwd" } },
341
341
  }));
@@ -361,7 +361,7 @@ console.log("\nFile Sandbox");
361
361
  const server = new McpServer("/test-sandbox2", "Sandbox Test 2");
362
362
  registerDevTools(server);
363
363
 
364
- const resp = JSON.parse(server.handleMessage({
364
+ const resp = JSON.parse(await server.handleMessage({
365
365
  jsonrpc: "2.0", id: 1, method: "tools/call",
366
366
  params: { name: "file_write", arguments: { path: "../../evil.txt", content: "hacked" } },
367
367
  }));
@@ -401,11 +401,11 @@ console.log("\nDefensive Write Helpers");
401
401
  * For string returns the inner text is the raw string; for object returns
402
402
  * it is JSON-encoded — try to parse it back, fall through to the raw text.
403
403
  */
404
- function callTool(server: McpServer, name: string, args: Record<string, unknown>): {
404
+ async function callTool(server: McpServer, name: string, args: Record<string, unknown>): Promise<{
405
405
  rpc: { jsonrpc?: string; id?: unknown; result?: unknown; error?: { code: number; message: string } };
406
406
  result: Record<string, unknown> | string | null;
407
- } {
408
- const rpc = JSON.parse(server.handleMessage({
407
+ }> {
408
+ const rpc = JSON.parse(await server.handleMessage({
409
409
  jsonrpc: "2.0", id: 1, method: "tools/call",
410
410
  params: { name, arguments: args },
411
411
  }));
@@ -431,7 +431,7 @@ function callTool(server: McpServer, name: string, args: Record<string, unknown>
431
431
  try {
432
432
  const server = new McpServer("/test-prose", "Prose Test");
433
433
  registerDevTools(server);
434
- const { result } = callTool(server, "file_write", {
434
+ const { result } = await callTool(server, "file_write", {
435
435
  path: "The plan requires implementing a new feature for users.ts",
436
436
  content: "x",
437
437
  });
@@ -455,7 +455,7 @@ function callTool(server: McpServer, name: string, args: Record<string, unknown>
455
455
  try {
456
456
  const server = new McpServer("/test-normalize", "Normalize Test");
457
457
  registerDevTools(server);
458
- const { result } = callTool(server, "file_write", {
458
+ const { result } = await callTool(server, "file_write", {
459
459
  path: "routes/foo.ts",
460
460
  content: "export default async function (req, res) {}\n",
461
461
  });
@@ -492,7 +492,7 @@ function callTool(server: McpServer, name: string, args: Record<string, unknown>
492
492
  fs.mkdirSync(path.dirname(target), { recursive: true });
493
493
  fs.writeFileSync(target, "original content\n", "utf-8");
494
494
 
495
- const { result } = callTool(server, "file_write", {
495
+ const { result } = await callTool(server, "file_write", {
496
496
  path: "src/routes/foo.ts",
497
497
  content: "new content that is reasonably similar in length to the old one\n",
498
498
  });
@@ -531,7 +531,7 @@ function callTool(server: McpServer, name: string, args: Record<string, unknown>
531
531
  fs.writeFileSync(target, original, "utf-8");
532
532
 
533
533
  // Overwrite with 50 bytes — should be REFUSED (50/500 = 10% < 30%)
534
- const { result } = callTool(server, "file_write", {
534
+ const { result } = await callTool(server, "file_write", {
535
535
  path: "src/routes/big.ts",
536
536
  content: "y".repeat(50),
537
537
  });
@@ -562,7 +562,7 @@ function callTool(server: McpServer, name: string, args: Record<string, unknown>
562
562
  try {
563
563
  const server = new McpServer("/test-passthrough", "Passthrough Test");
564
564
  registerDevTools(server);
565
- const { result } = callTool(server, "file_write", {
565
+ const { result } = await callTool(server, "file_write", {
566
566
  path: "src/routes/foo.ts",
567
567
  content: "export default async function (req, res) {}\n",
568
568
  });
@@ -602,7 +602,7 @@ function callTool(server: McpServer, name: string, args: Record<string, unknown>
602
602
  try {
603
603
  const server = new McpServer("/test-verify-js", "Verify JS");
604
604
  registerDevTools(server);
605
- const { result } = callTool(server, "file_write", {
605
+ const { result } = await callTool(server, "file_write", {
606
606
  path: "src/routes/broken.js",
607
607
  content: "const x = ;\n",
608
608
  });
@@ -635,7 +635,7 @@ function callTool(server: McpServer, name: string, args: Record<string, unknown>
635
635
  const server = new McpServer("/test-verify-skip-ext", "Verify Skip Ext");
636
636
  registerDevTools(server);
637
637
  // .twig content that would obviously fail any JS parser
638
- const { result } = callTool(server, "file_write", {
638
+ const { result } = await callTool(server, "file_write", {
639
639
  path: "src/templates/page.twig",
640
640
  content: "{% if not valid js %}<h1>Hi</h1>{% endif %}\n",
641
641
  });
@@ -668,7 +668,7 @@ function callTool(server: McpServer, name: string, args: Record<string, unknown>
668
668
  const server = new McpServer("/test-verify-skip-outside", "Verify Skip Outside");
669
669
  registerDevTools(server);
670
670
  // Broken JS placed under tests/ — must NOT be checked
671
- const { result } = callTool(server, "file_write", {
671
+ const { result } = await callTool(server, "file_write", {
672
672
  path: "tests/foo.js",
673
673
  content: "const x = ;\n",
674
674
  });
@@ -18,6 +18,32 @@ import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import * as os from "node:os";
20
20
  import { spawnSync } from "node:child_process";
21
+ import { createRequire } from "node:module";
22
+
23
+ // Synchronous CommonJS-style require that works under real ESM (where the
24
+ // bare `require` global is undefined). Dev-tool handlers are synchronous, so
25
+ // they can't `await import()` — this gives them a working require. Mirrors the
26
+ // pattern already used by the ORM adapters (mysql.ts, postgres.ts, etc.).
27
+ const req = createRequire(import.meta.url);
28
+
29
+ /**
30
+ * Require a sibling @tina4 workspace package (orm / swagger / frond) in a way
31
+ * that works whether we're running from source (monorepo, where the package's
32
+ * `exports` only exposes the TS `import` condition that `require()` can't see)
33
+ * or as an installed dependency (where the package name resolves directly).
34
+ *
35
+ * Tries the package name first; on failure falls back to the in-repo source
36
+ * path relative to this file (packages/core/src → packages/<name>/src). This
37
+ * is why `route_list`, `database_query`, `swagger_spec`, etc. work when a real
38
+ * MCP client hits /__dev/mcp from a from-source dev server.
39
+ */
40
+ function reqSibling(pkg: "orm" | "swagger" | "frond"): Record<string, unknown> {
41
+ try {
42
+ return req(`@tina4/${pkg}`) as Record<string, unknown>;
43
+ } catch {
44
+ return req(`../../${pkg}/src/index.ts`) as Record<string, unknown>;
45
+ }
46
+ }
21
47
 
22
48
  // ── Types ─────────────────────────────────────────────────────
23
49
 
@@ -34,7 +60,11 @@ export interface McpToolDefinition {
34
60
  name: string;
35
61
  description: string;
36
62
  inputSchema: JsonSchema;
37
- handler: (args: Record<string, unknown>) => unknown;
63
+ // Handlers may be sync or async — the dispatch (`_handleToolsCall`) awaits the
64
+ // return value, so DB tools that hit the async Database wrapper resolve before
65
+ // the result is formatted. A sync handler's plain return passes through awaiting
66
+ // unchanged.
67
+ handler: (args: Record<string, unknown>) => unknown | Promise<unknown>;
38
68
  }
39
69
 
40
70
  export interface McpResourceDefinition {
@@ -251,7 +281,13 @@ export class McpServer {
251
281
  });
252
282
  }
253
283
 
254
- handleMessage(rawData: string | Record<string, unknown>): string {
284
+ // Async since the DB dev-tool handlers (database_query/execute/tables/columns,
285
+ // migration_*, seed_table, project_overview) reach the Database wrapper on
286
+ // `globalThis.__tina4_db`, whose read/write methods are async. The handler is
287
+ // awaited below; sync handlers (the file/plan/route tools) are unaffected
288
+ // because awaiting a non-Promise resolves immediately. Returns a
289
+ // Promise<string> — every caller must await it.
290
+ async handleMessage(rawData: string | Record<string, unknown>): Promise<string> {
255
291
  let method: string;
256
292
  let params: Record<string, unknown>;
257
293
  let requestId: number | string | null;
@@ -278,7 +314,7 @@ export class McpServer {
278
314
  }
279
315
 
280
316
  try {
281
- const result = handler(params);
317
+ const result = await handler(params);
282
318
  if (requestId === null) {
283
319
  return ""; // Notification — no response
284
320
  }
@@ -323,7 +359,7 @@ export class McpServer {
323
359
  return { tools };
324
360
  }
325
361
 
326
- private _handleToolsCall(params: Record<string, unknown>): Record<string, unknown> {
362
+ private async _handleToolsCall(params: Record<string, unknown>): Promise<Record<string, unknown>> {
327
363
  const toolName = params.name as string | undefined;
328
364
  if (!toolName) {
329
365
  throw new Error("Missing tool name");
@@ -335,7 +371,9 @@ export class McpServer {
335
371
  }
336
372
 
337
373
  const args = (params.arguments as Record<string, unknown>) || {};
338
- const result = tool.handler(args);
374
+ // Tool handlers may be async (the DB tools await the Database wrapper); a
375
+ // sync handler's plain return value passes through `await` unchanged.
376
+ const result = await tool.handler(args);
339
377
 
340
378
  // Format result as MCP content
341
379
  let content: { type: string; text: string }[];
@@ -412,7 +450,7 @@ export class McpServer {
412
450
  const ssePath = `${this.path}/sse`;
413
451
 
414
452
  router
415
- .post(msgPath, (req: unknown, res: unknown) => {
453
+ .post(msgPath, async (req: unknown, res: unknown) => {
416
454
  const request = req as { body: unknown; url?: string };
417
455
  const response = res as ((data: unknown, status?: number, contentType?: string) => unknown);
418
456
  const body = request.body;
@@ -422,7 +460,7 @@ export class McpServer {
422
460
  } else {
423
461
  raw = typeof body === "string" ? body : String(body);
424
462
  }
425
- const result = server.handleMessage(raw);
463
+ const result = await server.handleMessage(raw);
426
464
  if (!result) {
427
465
  return response("", 204);
428
466
  }
@@ -484,6 +522,7 @@ export class McpServer {
484
522
  // ── Decorator API ──────────────────────────────────────────────
485
523
 
486
524
  let _defaultServer: McpServer | null = null;
525
+ let _defaultToolsRegistered = false;
487
526
 
488
527
  function _getDefaultServer(): McpServer {
489
528
  if (_defaultServer === null) {
@@ -492,6 +531,24 @@ function _getDefaultServer(): McpServer {
492
531
  return _defaultServer;
493
532
  }
494
533
 
534
+ /**
535
+ * The default `/__dev/mcp` MCP server with the built-in dev tools registered.
536
+ *
537
+ * This is the single shared instance backing BOTH the browser REST shim
538
+ * (`/__dev/api/mcp/tools` + `/__dev/api/mcp/call`) and the JSON-RPC + SSE
539
+ * endpoints (`/__dev/mcp[/message]` + `/__dev/mcp/sse`) that real MCP clients
540
+ * (Claude Code/Desktop) speak. Tools are registered exactly once (idempotent).
541
+ * Mirrors Python's default MCP server used by `get_api_handlers()`.
542
+ */
543
+ export function getDefaultDevServer(): McpServer {
544
+ const server = _getDefaultServer();
545
+ if (!_defaultToolsRegistered) {
546
+ registerDevTools(server);
547
+ _defaultToolsRegistered = true;
548
+ }
549
+ return server;
550
+ }
551
+
495
552
  /**
496
553
  * Register a function as an MCP tool.
497
554
  *
@@ -787,13 +844,12 @@ export function registerDevTools(server: McpServer): void {
787
844
 
788
845
  server.registerTool(
789
846
  "database_query",
790
- (args) => {
847
+ async (args) => {
791
848
  try {
792
- const { initDatabase } = require("@tina4/orm");
793
849
  const db = (globalThis as any).__tina4_db;
794
850
  if (!db) return { error: "No database connection" };
795
851
  const params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
796
- const result = db.fetch(args.sql as string, params);
852
+ const result = await db.fetch(args.sql as string, params);
797
853
  return { records: result.records || [], count: result.count || 0 };
798
854
  } catch (e) {
799
855
  return { error: (e as Error).message };
@@ -808,14 +864,14 @@ export function registerDevTools(server: McpServer): void {
808
864
 
809
865
  server.registerTool(
810
866
  "database_execute",
811
- (args) => {
867
+ async (args) => {
812
868
  try {
813
869
  const db = (globalThis as any).__tina4_db;
814
870
  if (!db) return { error: "No database connection" };
815
871
  const params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
816
- const result = db.execute(args.sql as string, params);
817
- db.commit?.();
818
- return { success: true, affected_rows: result?.count ?? 0 };
872
+ const result = await db.execute(args.sql as string, params);
873
+ await db.commit?.();
874
+ return { success: true, affected_rows: (result as any)?.count ?? 0 };
819
875
  } catch (e) {
820
876
  return { error: (e as Error).message };
821
877
  }
@@ -829,11 +885,11 @@ export function registerDevTools(server: McpServer): void {
829
885
 
830
886
  server.registerTool(
831
887
  "database_tables",
832
- (_args) => {
888
+ async (_args) => {
833
889
  try {
834
890
  const db = (globalThis as any).__tina4_db;
835
891
  if (!db) return { error: "No database connection" };
836
- return db.getTables?.() ?? [];
892
+ return (await db.getTables?.()) ?? [];
837
893
  } catch (e) {
838
894
  return { error: (e as Error).message };
839
895
  }
@@ -844,11 +900,11 @@ export function registerDevTools(server: McpServer): void {
844
900
 
845
901
  server.registerTool(
846
902
  "database_columns",
847
- (args) => {
903
+ async (args) => {
848
904
  try {
849
905
  const db = (globalThis as any).__tina4_db;
850
906
  if (!db) return { error: "No database connection" };
851
- return db.getColumns?.(args.table as string) ?? [];
907
+ return (await db.getColumns?.(args.table as string)) ?? [];
852
908
  } catch (e) {
853
909
  return { error: (e as Error).message };
854
910
  }
@@ -863,8 +919,16 @@ export function registerDevTools(server: McpServer): void {
863
919
  "route_list",
864
920
  (_args) => {
865
921
  try {
866
- const { defaultRouter } = require("@tina4/core");
867
- const routes = defaultRouter?.listRoutes?.() ?? [];
922
+ // Prefer the active server router (set by startServer) so file-discovered
923
+ // routes are included; startServer builds a fresh Router rather than using
924
+ // defaultRouter. Fall back to defaultRouter when no server is running.
925
+ // route_list lives inside @tina4/core, so reach the router via the local
926
+ // module — req("@tina4/core") depends on a built dist/ and fails under a
927
+ // from-source ESM runtime (the real MCP-client code path).
928
+ const { defaultRouter } = req("./router.js") as typeof import("./router.js");
929
+ const activeRouter = (globalThis as any).__tina4_router;
930
+ const router = activeRouter ?? defaultRouter;
931
+ const routes = router?.listRoutes?.() ?? [];
868
932
  return routes.map((r: any) => ({
869
933
  method: r.method || "",
870
934
  path: r.pattern || r.path || "",
@@ -901,10 +965,12 @@ export function registerDevTools(server: McpServer): void {
901
965
  "swagger_spec",
902
966
  (_args) => {
903
967
  try {
904
- const { generateSpec } = require("@tina4/swagger");
905
- return generateSpec?.() ?? { info: "Swagger not available" };
906
- } catch {
907
- return { info: "Swagger package not loaded" };
968
+ const { generate } = reqSibling("swagger") as { generate?: (routes: unknown[], models?: unknown) => unknown };
969
+ const { defaultRouter } = req("./router.js") as typeof import("./router.js");
970
+ const routes = defaultRouter?.getRoutes?.() ?? [];
971
+ return generate?.(routes, []) ?? { info: "Swagger not available" };
972
+ } catch (e) {
973
+ return { error: (e as Error).message };
908
974
  }
909
975
  },
910
976
  "Return the OpenAPI 3.0.3 JSON spec",
@@ -917,9 +983,11 @@ export function registerDevTools(server: McpServer): void {
917
983
  "template_render",
918
984
  (args) => {
919
985
  try {
920
- const { renderTemplate } = require("@tina4/twig");
986
+ const { Frond } = reqSibling("frond") as { Frond?: new (dir?: string) => { renderString: (s: string, d?: Record<string, unknown>) => string } };
987
+ if (!Frond) return "Template engine not available";
921
988
  const data = typeof args.data === "string" ? JSON.parse(args.data as string) : (args.data || {});
922
- return renderTemplate?.(args.template as string, data) ?? "Template engine not available";
989
+ const frond = new Frond(path.join(projectRoot, "src", "templates"));
990
+ return frond.renderString(args.template as string, data as Record<string, unknown>);
923
991
  } catch (e) {
924
992
  return { error: (e as Error).message };
925
993
  }
@@ -1066,7 +1134,7 @@ export function registerDevTools(server: McpServer): void {
1066
1134
 
1067
1135
  server.registerTool(
1068
1136
  "migration_status",
1069
- (_args) => {
1137
+ async (_args) => {
1070
1138
  try {
1071
1139
  const db = (globalThis as any).__tina4_db;
1072
1140
  if (!db) return { error: "No database connection" };
@@ -1099,7 +1167,7 @@ export function registerDevTools(server: McpServer): void {
1099
1167
 
1100
1168
  server.registerTool(
1101
1169
  "migration_run",
1102
- (_args) => {
1170
+ async (_args) => {
1103
1171
  try {
1104
1172
  const db = (globalThis as any).__tina4_db;
1105
1173
  if (!db) return { error: "No database connection" };
@@ -1118,7 +1186,7 @@ export function registerDevTools(server: McpServer): void {
1118
1186
  "queue_status",
1119
1187
  (args) => {
1120
1188
  try {
1121
- const { Queue } = require("@tina4/core");
1189
+ const { Queue } = req("./queue.js") as typeof import("./queue.js");
1122
1190
  const topic = (args.topic as string) || "default";
1123
1191
  const q = new Queue({ topic });
1124
1192
  return {
@@ -1166,7 +1234,7 @@ export function registerDevTools(server: McpServer): void {
1166
1234
  // latest snapshot once available. The very first call may report the
1167
1235
  // pending placeholder; subsequent calls return live figures.
1168
1236
  try {
1169
- const mod = require("@tina4/core");
1237
+ const mod = req("./cache.js") as typeof import("./cache.js");
1170
1238
  const stats = mod.cacheStats?.();
1171
1239
  if (stats && typeof stats.then === "function") {
1172
1240
  stats.then((s: unknown) => { _lastCacheStats = s as Record<string, unknown>; }).catch(() => {});
@@ -1222,12 +1290,9 @@ export function registerDevTools(server: McpServer): void {
1222
1290
  "error_log",
1223
1291
  (args) => {
1224
1292
  try {
1225
- const { DevAdmin } = require("@tina4/core");
1226
- const tracker = DevAdmin?.errorTracker;
1227
- if (tracker?.get) {
1228
- return tracker.get(args.limit || 20);
1229
- }
1230
- return [];
1293
+ const { ErrorTracker } = req("./devAdmin.js") as typeof import("./devAdmin.js");
1294
+ const limit = (args.limit as number) || 20;
1295
+ return ErrorTracker.get().slice(0, limit);
1231
1296
  } catch {
1232
1297
  return [];
1233
1298
  }
@@ -1256,13 +1321,13 @@ export function registerDevTools(server: McpServer): void {
1256
1321
 
1257
1322
  server.registerTool(
1258
1323
  "seed_table",
1259
- (args) => {
1324
+ async (args) => {
1260
1325
  try {
1261
- const { seedTable } = require("@tina4/orm");
1326
+ const { seedTable } = reqSibling("orm") as { seedTable?: (db: unknown, table: string, count: number) => number | Promise<number> };
1262
1327
  const db = (globalThis as any).__tina4_db;
1263
1328
  if (!db) return { error: "No database connection" };
1264
1329
  const count = (args.count as number) || 10;
1265
- const inserted = seedTable?.(db, args.table as string, count) ?? 0;
1330
+ const inserted = (await seedTable?.(db, args.table as string, count)) ?? 0;
1266
1331
  return { table: args.table, inserted };
1267
1332
  } catch (e) {
1268
1333
  return { error: (e as Error).message };
@@ -1305,9 +1370,9 @@ export function registerDevTools(server: McpServer): void {
1305
1370
  // Ported from Python's tina4_python.mcp.tools — names match exactly.
1306
1371
  // The Plan storage format is byte-for-byte compatible across frameworks.
1307
1372
 
1308
- const loadPlan = () => require("./plan.js").Plan as typeof import("./plan.js").Plan;
1373
+ const loadPlan = () => req("./plan.js").Plan as typeof import("./plan.js").Plan;
1309
1374
  const loadIndex = () =>
1310
- require("./projectIndex.js").ProjectIndex as typeof import("./projectIndex.js").ProjectIndex;
1375
+ req("./projectIndex.js").ProjectIndex as typeof import("./projectIndex.js").ProjectIndex;
1311
1376
 
1312
1377
  server.registerTool(
1313
1378
  "plan_current",
@@ -1429,14 +1494,14 @@ export function registerDevTools(server: McpServer): void {
1429
1494
 
1430
1495
  server.registerTool(
1431
1496
  "project_overview",
1432
- () => {
1497
+ async () => {
1433
1498
  const out: Record<string, unknown> = {};
1434
1499
  try { out.index = loadIndex().overview(); } catch (e) { out.index = { error: (e as Error).message }; }
1435
1500
  try { out.plans = loadPlan().listPlans(); } catch (e) { out.plans = { error: (e as Error).message }; }
1436
1501
  try { out.current_plan = loadPlan().current(); } catch (e) { out.current_plan = { error: (e as Error).message }; }
1437
1502
  try {
1438
1503
  const db = (globalThis as any).__tina4_db;
1439
- out.tables = db?.getTables?.() ?? [];
1504
+ out.tables = (await db?.getTables?.()) ?? [];
1440
1505
  } catch (e) { out.tables = { error: (e as Error).message }; }
1441
1506
  return out;
1442
1507
  },
@@ -1633,7 +1698,7 @@ export function registerDevTools(server: McpServer): void {
1633
1698
  "git_status",
1634
1699
  () => {
1635
1700
  try {
1636
- const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
1701
+ const { execFileSync } = req("node:child_process") as typeof import("node:child_process");
1637
1702
  const cwd = path.resolve(process.cwd());
1638
1703
  const run = (args: string[]): string => {
1639
1704
  return execFileSync("git", args, { cwd, timeout: 3000, encoding: "utf-8" }).toString().trim();
@@ -1685,7 +1750,7 @@ export function registerDevTools(server: McpServer): void {
1685
1750
  (args) => {
1686
1751
  try {
1687
1752
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1688
- const { Docs } = require("./docs.js") as typeof import("./docs.js");
1753
+ const { Docs } = req("./docs.js") as typeof import("./docs.js");
1689
1754
  return Docs.mcpSearch(
1690
1755
  (args.query as string) || "",
1691
1756
  parseInt(String(args.k ?? 5), 10) || 5,
@@ -1711,7 +1776,7 @@ export function registerDevTools(server: McpServer): void {
1711
1776
  (args) => {
1712
1777
  try {
1713
1778
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1714
- const { Docs } = require("./docs.js") as typeof import("./docs.js");
1779
+ const { Docs } = req("./docs.js") as typeof import("./docs.js");
1715
1780
  const spec = Docs.mcpClass((args.name as string) || "");
1716
1781
  return spec ?? { error: `class not found: ${args.name}` };
1717
1782
  } catch (e) {
@@ -1727,7 +1792,7 @@ export function registerDevTools(server: McpServer): void {
1727
1792
  (args) => {
1728
1793
  try {
1729
1794
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1730
- const { Docs } = require("./docs.js") as typeof import("./docs.js");
1795
+ const { Docs } = req("./docs.js") as typeof import("./docs.js");
1731
1796
  // PHP names the param `class`, Python names it `class_` — Node.js MCP
1732
1797
  // accepts the raw `class` field from the JSON-RPC payload.
1733
1798
  const cls = (args.class as string) || (args.class_name as string) || "";
@@ -756,6 +756,13 @@ ${reset}
756
756
  const router = new Router();
757
757
  const middleware = new MiddlewareChain();
758
758
 
759
+ // Expose the active server router globally so dev tools (e.g. the MCP
760
+ // route_list tool) can introspect the real, fully-populated route table —
761
+ // startServer builds a fresh Router rather than using defaultRouter, so
762
+ // file-discovered routes only live here. Mirrors the globalThis.__tina4_db
763
+ // hook other dev tools read.
764
+ (globalThis as any).__tina4_router = router;
765
+
759
766
  // Merge routes registered via top-level get(), post(), etc.
760
767
  for (const route of defaultRouter.getRoutes()) {
761
768
  router.addRoute(route);
@@ -1379,17 +1386,11 @@ ${reset}
1379
1386
  // Assign to module-level so handle() can dispatch without a server reference
1380
1387
  _dispatchFn = dispatch;
1381
1388
 
1382
- // When dual-port is active (debug mode + no TINA4_NO_AI_PORT), tag main port requests
1383
- // as AI port to suppress reload/toolbar injection. Test port (port+1000) gets full reload.
1384
- const _dualPortActive = isTruthy(process.env.TINA4_DEBUG ?? "") &&
1385
- !isTruthy(process.env.TINA4_NO_AI_PORT ?? "");
1386
- const mainPortDispatch = _dualPortActive
1387
- ? async (req: IncomingMessage, res: ServerResponse) => {
1388
- (req as any)._tina4AiPort = true;
1389
- await dispatch(req, res);
1390
- }
1391
- : dispatch;
1392
- const server = createServer(mainPortDispatch);
1389
+ // Dual-port (debug + no TINA4_NO_AI_PORT): the MAIN port hot-reloads for the human
1390
+ // dev; the stable AI port (port+1000, created below) suppresses reload/toolbar so an
1391
+ // AI tool can drive it without its own edits triggering refreshes. The tina4 client
1392
+ // posts /__dev/api/reload to the MAIN port. Matches Python (master).
1393
+ const server = createServer(dispatch);
1393
1394
 
1394
1395
  return new Promise((resolvePromise) => {
1395
1396
  server.listen(port, host, () => {
@@ -1405,16 +1406,17 @@ ${reset}
1405
1406
  // Determine server mode label
1406
1407
  const serverMode = isDebug ? "single" : (cluster.isWorker ? "cluster-worker" : "single");
1407
1408
 
1408
- // AI dual-port: main port = AI dev port (no reload), port+1000 = user testing port (hot-reload)
1409
- // When TINA4_DEBUG=true and TINA4_NO_AI_PORT is not set, main server suppresses reload/toolbar
1410
- // and a second server on port+1000 provides the normal hot-reload experience.
1409
+ // AI dual-port: main port = hot-reload (human dev); port+1000 = stable AI port
1410
+ // (reload/toolbar suppressed) so an AI tool can drive it without its edits
1411
+ // triggering refreshes. The tina4 client fires reloads at the MAIN port. Matches Python.
1411
1412
  const noAiPort = isTruthy(process.env.TINA4_NO_AI_PORT ?? "");
1412
1413
  let aiServer: ReturnType<typeof createServer> | null = null;
1413
1414
  let testPort = port + 1000;
1414
1415
 
1415
1416
  if (isDebug && !noAiPort) {
1416
- // Test port (port+1000): normal dispatch with full hot-reload
1417
+ // Stable AI port (port+1000): tag requests so /__dev_reload + toolbar are suppressed.
1417
1418
  aiServer = createServer(async (req, res) => {
1419
+ (req as any)._tina4AiPort = true;
1418
1420
  await dispatch(req, res);
1419
1421
  });
1420
1422
 
@@ -1451,11 +1453,8 @@ ${reset}
1451
1453
  }
1452
1454
  const noBrowser = isTruthy(process.env.TINA4_NO_BROWSER);
1453
1455
  if (!noBrowser) {
1454
- // Open browser on test port (hot-reload) if available, otherwise main port
1455
- const browserTarget = (isDebug && !noAiPort && aiServer)
1456
- ? `http://${displayHost}:${testPort}`
1457
- : `http://${displayHost}:${port}`;
1458
- openBrowser(browserTarget);
1456
+ // Open the browser on the MAIN port — that's the hot-reload port.
1457
+ openBrowser(`http://${displayHost}:${port}`);
1459
1458
  }
1460
1459
  resolvePromise({
1461
1460
  close: () => {
@@ -161,6 +161,21 @@ export function setAdapter(adapter: DatabaseAdapter): DatabaseAdapter {
161
161
  return activeAdapter;
162
162
  }
163
163
 
164
+ /**
165
+ * Publish the live Database wrapper on `globalThis.__tina4_db` so framework
166
+ * tooling that runs outside the request/ORM path can reach it. The built-in
167
+ * MCP dev-tool handlers (database_query/execute/tables/columns, migration_*,
168
+ * seed_table, project_overview in `@tina4/core`'s mcp.ts) read this global; it
169
+ * is the single chokepoint every `initDatabase()` / `Database.create()` return
170
+ * path flows through, so the global is always set once a connection exists.
171
+ *
172
+ * Returns the same instance so it composes cleanly around a `return`.
173
+ */
174
+ function exposeDb(db: Database): Database {
175
+ (globalThis as any).__tina4_db = db;
176
+ return db;
177
+ }
178
+
164
179
  /**
165
180
  * Clear the request-scoped query cache on every live connection at the start of
166
181
  * each HTTP request, so request-scoped caching never serves rows across
@@ -535,7 +550,7 @@ export class Database {
535
550
  db.adapter = null; // Don't use single-adapter path
536
551
  db.adapterFactory = async () => wrapWithCache(await createAdapterFromUrl(url, username, password), { sharedCache });
537
552
  db.dbType = parsed.type;
538
- return db;
553
+ return exposeDb(db);
539
554
  }
540
555
 
541
556
  // Single-connection mode — wrap once and share the SAME wrapped adapter
@@ -545,7 +560,7 @@ export class Database {
545
560
  const wrapped = setAdapter(adapter);
546
561
  const db = new Database(wrapped);
547
562
  db.dbType = parsed.type;
548
- return db;
563
+ return exposeDb(db);
549
564
  }
550
565
 
551
566
  /**
@@ -1217,10 +1232,11 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1217
1232
  if (pool > 0) {
1218
1233
  // Pool-aware path — delegate to Database.create which manages
1219
1234
  // round-robin adapter rotation and async-local-storage transaction pinning.
1220
- return Database.create(url, resolvedUser, resolvedPassword, pool);
1235
+ // Database.create already exposes the global; exposeDb here is idempotent.
1236
+ return exposeDb(await Database.create(url, resolvedUser, resolvedPassword, pool));
1221
1237
  }
1222
1238
  const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
1223
- return new Database(setAdapter(adapter));
1239
+ return exposeDb(new Database(setAdapter(adapter)));
1224
1240
  }
1225
1241
 
1226
1242
  // Legacy config path — normalize "sqlserver" to "mssql"
@@ -1245,7 +1261,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1245
1261
  case "sqlite": {
1246
1262
  const { SQLiteAdapter } = await import("./adapters/sqlite.js");
1247
1263
  const adapter = new SQLiteAdapter(config?.path ?? "./data/tina4.db");
1248
- return new Database(setAdapter(adapter));
1264
+ return exposeDb(new Database(setAdapter(adapter)));
1249
1265
  }
1250
1266
  case "postgres": {
1251
1267
  const { PostgresAdapter } = await import("./adapters/postgres.js");
@@ -1257,7 +1273,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1257
1273
  database: config?.database,
1258
1274
  });
1259
1275
  await adapter.connect();
1260
- return new Database(setAdapter(adapter));
1276
+ return exposeDb(new Database(setAdapter(adapter)));
1261
1277
  }
1262
1278
  case "mysql": {
1263
1279
  const { MysqlAdapter } = await import("./adapters/mysql.js");
@@ -1269,7 +1285,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1269
1285
  database: config?.database,
1270
1286
  });
1271
1287
  await adapter.connect();
1272
- return new Database(setAdapter(adapter));
1288
+ return exposeDb(new Database(setAdapter(adapter)));
1273
1289
  }
1274
1290
  case "mssql": {
1275
1291
  const { MssqlAdapter } = await import("./adapters/mssql.js");
@@ -1281,7 +1297,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1281
1297
  database: config?.database,
1282
1298
  });
1283
1299
  await adapter.connect();
1284
- return new Database(setAdapter(adapter));
1300
+ return exposeDb(new Database(setAdapter(adapter)));
1285
1301
  }
1286
1302
  case "firebird": {
1287
1303
  const { FirebirdAdapter } = await import("./adapters/firebird.js");
@@ -1293,7 +1309,7 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1293
1309
  database: config?.database,
1294
1310
  });
1295
1311
  await adapter.connect();
1296
- return new Database(setAdapter(adapter));
1312
+ return exposeDb(new Database(setAdapter(adapter)));
1297
1313
  }
1298
1314
  case "mongodb": {
1299
1315
  const { MongodbAdapter } = await import("./adapters/mongodb.js");
@@ -1306,14 +1322,14 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
1306
1322
  const connectionString = `mongodb://${creds}${host}:${port}/${database}`;
1307
1323
  const adapter = new MongodbAdapter(connectionString);
1308
1324
  await adapter.connect();
1309
- return new Database(setAdapter(adapter));
1325
+ return exposeDb(new Database(setAdapter(adapter)));
1310
1326
  }
1311
1327
  case "odbc": {
1312
1328
  const { OdbcAdapter } = await import("./adapters/odbc.js");
1313
1329
  const connStr = config?.connectionString ?? config?.url?.replace(/^odbc:\/\/\//, "") ?? "";
1314
1330
  const adapter = new OdbcAdapter({ connectionString: connStr });
1315
1331
  await adapter.connect();
1316
- return new Database(setAdapter(adapter));
1332
+ return exposeDb(new Database(setAdapter(adapter)));
1317
1333
  }
1318
1334
  default:
1319
1335
  throw new Error(`Unknown database type: ${type}`);