tina4-nodejs 3.13.34 → 3.13.36

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.
@@ -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
  /**
@@ -648,14 +665,37 @@ const handleReload: RouteHandler = async (req, res) => {
648
665
  const { rediscoverRoutes } = await import("./routeDiscovery.js");
649
666
  const newRoutes = await rediscoverRoutes();
650
667
  if (newRoutes.length > 0) {
651
- const { defaultRouter } = await import("./router.js");
652
- for (const route of newRoutes) defaultRouter.addRoute(route);
653
- console.log(` Re-discovered ${newRoutes.length} new route(s) on reload`);
668
+ // Add to the LIVE server router — startServer() builds a fresh Router and
669
+ // exposes it as globalThis.__tina4_router; that's the instance dispatch
670
+ // matches against. Adding to defaultRouter would land the re-imported
671
+ // handler in a table nobody serves from, so the stale route keeps winning
672
+ // (an edited route would never hot-reload). addRoute() replaces by pattern,
673
+ // so the fresh handler overwrites the old one in place. Fall back to
674
+ // defaultRouter when no server is running (e.g. unit tests).
675
+ const liveRouter = (globalThis as any).__tina4_router;
676
+ const target = liveRouter ?? (await import("./router.js")).defaultRouter;
677
+ for (const route of newRoutes) target.addRoute(route);
678
+ console.log(` Re-discovered ${newRoutes.length} route(s) on reload`);
654
679
  }
655
680
  } catch (err) {
656
681
  console.error(` Re-discover on reload failed:`, err);
657
682
  }
658
683
 
684
+ // WebSocket-primary reload: push an instant message to every browser
685
+ // connected on /__dev_reload. The toolbar client (and the dev-admin
686
+ // dashboard) act on this immediately — the mtime poll is only a fallback for
687
+ // when the socket is down. CSS changes swap stylesheets; everything else
688
+ // triggers a full page reload, so we normalise the wire `type` to
689
+ // "css"/"reload". The HTTP response still echoes the caller's original type.
690
+ // Wrapped so a broadcast failure (or zero clients) never 500s the endpoint.
691
+ const wsType = reloadType === "css" ? "css" : "reload";
692
+ try {
693
+ const { devReloadWs } = await import("./websocket.js");
694
+ devReloadWs.broadcast(JSON.stringify({ type: wsType, file: _reloadFile, mtime: _reloadMtime }));
695
+ } catch (err) {
696
+ console.error(` Dev-reload WebSocket broadcast failed:`, err);
697
+ }
698
+
659
699
  res.json({ ok: true, type: reloadType });
660
700
  };
661
701
 
@@ -1717,35 +1757,151 @@ const handleExecute: RouteHandler = async (req, res) => {
1717
1757
  }
1718
1758
  };
1719
1759
 
1720
- const handleFiles: RouteHandler = (req, res) => {
1760
+ // --- file-browser noise filter + git decoration (mirrors PHP/Python dev-admin) ---
1761
+ const DEV_FILES_IGNORED = new Set([
1762
+ "__pycache__", "node_modules", "vendor", ".git",
1763
+ "venv", ".venv", "dist", "target", ".tina4",
1764
+ ]);
1765
+
1766
+ // Hidden dot-entries are filtered too, except the env files.
1767
+ function devFilesHidden(name: string): boolean {
1768
+ if (DEV_FILES_IGNORED.has(name)) return true;
1769
+ return name.startsWith(".") && name !== ".env" && name !== ".env.example";
1770
+ }
1771
+
1772
+ // Same 4-status mapping Python/PHP use for a porcelain code.
1773
+ function devGitStatusLabel(code: string): string {
1774
+ if (code === "??") return "untracked";
1775
+ if (code.includes("M")) return "modified";
1776
+ if (code.includes("A")) return "added";
1777
+ if (code.includes("D")) return "deleted";
1778
+ return "clean";
1779
+ }
1780
+
1781
+ /**
1782
+ * Branch + porcelain status map for the file browser, mirroring PHP's
1783
+ * devAdminGit* helpers 1:1. Paths git reports are relative to the repo
1784
+ * root (always forward-slash); the project root may sit inside a larger
1785
+ * repo (monorepo), so the toplevel is returned too for rebasing. Degrades
1786
+ * to empty (every entry "clean") on any error / no git.
1787
+ */
1788
+ async function devGitInfo(root: string): Promise<{ branch: string; gitRoot: string | null; status: Map<string, string> }> {
1789
+ const empty = { branch: "", gitRoot: null as string | null, status: new Map<string, string>() };
1790
+ try {
1791
+ const { execFileSync } = await import("node:child_process");
1792
+ const git = (args: string[]): string | null => {
1793
+ try {
1794
+ return execFileSync("git", args, { cwd: root, timeout: 3000, encoding: "utf-8" }).toString();
1795
+ } catch {
1796
+ return null;
1797
+ }
1798
+ };
1799
+ const inside = git(["rev-parse", "--is-inside-work-tree"]);
1800
+ if (!inside || inside.trim() !== "true") return empty;
1801
+ const branch = (git(["rev-parse", "--abbrev-ref", "HEAD"]) ?? "").trim();
1802
+ const topRaw = git(["rev-parse", "--show-toplevel"]);
1803
+ const gitRoot = topRaw && topRaw.trim() !== ""
1804
+ ? topRaw.trim().replace(/\\/g, "/").replace(/\/+$/, "")
1805
+ : null;
1806
+ const status = new Map<string, string>();
1807
+ const porcelain = git(["status", "--porcelain", "-uall"]);
1808
+ if (porcelain) {
1809
+ for (const line of porcelain.split(/\r?\n/)) {
1810
+ if (line.length < 4) continue;
1811
+ const code = line.slice(0, 2).trim();
1812
+ let p = line.slice(3).trim();
1813
+ const arrow = p.indexOf(" -> "); // rename/copy — keep destination
1814
+ if (arrow !== -1) p = p.slice(arrow + 4);
1815
+ if (p === "") continue;
1816
+ status.set(p, code);
1817
+ }
1818
+ }
1819
+ return { branch, gitRoot, status };
1820
+ } catch {
1821
+ return empty;
1822
+ }
1823
+ }
1824
+
1825
+ const handleFiles: RouteHandler = async (req, res) => {
1826
+ // Response shape matches tina4-python / tina4-php 1:1 so the dev-admin SPA
1827
+ // works against every framework with no branching: each entry carries
1828
+ // `is_dir`, `has_children`, `git_status` and `size`; the payload carries the
1829
+ // git `branch`. Noise dirs + hidden dot-files (except .env/.env.example) are
1830
+ // filtered out.
1721
1831
  const url = new URL(req.url ?? "/", "http://localhost");
1722
1832
  const rel = url.searchParams.get("path") ?? ".";
1723
1833
  const root = resolve(process.cwd());
1724
1834
  const target = safeJoin(root, rel);
1725
- if (!target || !existsSync(target)) {
1726
- res.json({ error: `Path not found: ${rel}` }, 404);
1835
+ const { branch, gitRoot, status: gitStatus } = await devGitInfo(root);
1836
+
1837
+ // Missing/invalid paths return an empty-but-valid shape (not 404): the SPA
1838
+ // restores expanded-folder state from localStorage, and folders that don't
1839
+ // exist in this harness would otherwise spam the console with red 404s.
1840
+ if (!target || !existsSync(target) || !statSync(target).isDirectory()) {
1841
+ res.json({ path: rel, branch, entries: [], error: "not a directory" });
1727
1842
  return;
1728
1843
  }
1729
- const stat = statSync(target);
1730
- if (!stat.isDirectory()) {
1731
- res.json({ error: `Not a directory: ${rel}` }, 400);
1732
- return;
1844
+
1845
+ // Rebase entry paths onto the git repo root when the project sits inside a
1846
+ // larger repo. Everything runs in forward-slash form.
1847
+ const rootFwd = root.replace(/\\/g, "/");
1848
+ let cwdInGit = "";
1849
+ if (gitRoot && gitRoot !== rootFwd && rootFwd.startsWith(gitRoot)) {
1850
+ cwdInGit = rootFwd.slice(gitRoot.length).replace(/^\/+/, "");
1851
+ if (cwdInGit !== "") cwdInGit += "/";
1733
1852
  }
1734
- const entries = readdirSync(target, { withFileTypes: true })
1735
- .filter((e) => !e.name.startsWith(".") || e.name === ".env")
1736
- .map((e) => {
1737
- const full = join(target, e.name);
1738
- let size = 0;
1739
- try { size = e.isFile() ? statSync(full).size : 0; } catch { /* ignore */ }
1740
- return {
1741
- name: e.name,
1742
- type: e.isDirectory() ? "dir" : "file",
1743
- size,
1744
- path: relative(root, full),
1745
- };
1746
- })
1747
- .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
1748
- res.json({ path: relative(root, target) || ".", entries });
1853
+
1854
+ const entries: Array<Record<string, unknown>> = [];
1855
+ for (const name of readdirSync(target).sort()) { // alphabetical; no re-sort by type
1856
+ if (devFilesHidden(name)) continue;
1857
+ const full = join(target, name);
1858
+ const entryRel = relative(root, full).replace(/\\/g, "/");
1859
+
1860
+ let isDir = false;
1861
+ let size: number | null = null;
1862
+ try {
1863
+ const st = statSync(full);
1864
+ isDir = st.isDirectory();
1865
+ if (!isDir) size = st.size;
1866
+ } catch { /* unreadable entry */ }
1867
+
1868
+ // git status for this entry (same mapping PHP/Python use)
1869
+ const gitPath = cwdInGit + entryRel;
1870
+ let gitLabel = "clean";
1871
+ const code = gitStatus.get(gitPath);
1872
+ if (code !== undefined) {
1873
+ gitLabel = devGitStatusLabel(code);
1874
+ } else if (isDir) {
1875
+ const prefix = gitPath + "/"; // propagate dirty status from any child
1876
+ for (const [gf, gc] of gitStatus) {
1877
+ if (gf.startsWith(prefix)) { gitLabel = gc === "??" ? "untracked" : "modified"; break; }
1878
+ }
1879
+ }
1880
+
1881
+ // has_children: does the dir contain anything visible?
1882
+ let hasChildren: boolean | null = null;
1883
+ if (isDir) {
1884
+ hasChildren = false;
1885
+ try {
1886
+ for (const c of readdirSync(full)) {
1887
+ if (devFilesHidden(c)) continue;
1888
+ hasChildren = true;
1889
+ break;
1890
+ }
1891
+ } catch { /* ignore */ }
1892
+ }
1893
+
1894
+ entries.push({
1895
+ name,
1896
+ path: entryRel,
1897
+ is_dir: isDir,
1898
+ has_children: hasChildren,
1899
+ git_status: gitLabel,
1900
+ size,
1901
+ });
1902
+ }
1903
+
1904
+ res.json({ path: relative(root, target).replace(/\\/g, "/") || ".", branch, entries });
1749
1905
  };
1750
1906
 
1751
1907
  const handleFileRead: RouteHandler = (req, res) => {
@@ -1931,7 +2087,11 @@ const handleGitStatus: RouteHandler = async (_req, res) => {
1931
2087
 
1932
2088
  const handleMcpTools: RouteHandler = async (_req, res) => {
1933
2089
  try {
1934
- const { McpServer } = await import("./mcp.js");
2090
+ // Ensure the default /__dev/mcp server exists with its dev tools registered,
2091
+ // then enumerate every registered MCP server instance (app-defined servers
2092
+ // register themselves on construction too).
2093
+ const { McpServer, getDefaultDevServer } = await import("./mcp.js");
2094
+ getDefaultDevServer();
1935
2095
  const instances = (McpServer as unknown as { _instances: Array<any> })._instances || [];
1936
2096
  const tools: Array<{ server: string; name: string; description: string; inputSchema: unknown }> = [];
1937
2097
  for (const s of instances) {
@@ -1950,7 +2110,8 @@ const handleMcpCall: RouteHandler = async (req, res) => {
1950
2110
  const name = (body.name as string) || "";
1951
2111
  const args = (body.arguments as Record<string, unknown>) || {};
1952
2112
  try {
1953
- const { McpServer } = await import("./mcp.js");
2113
+ const { McpServer, getDefaultDevServer } = await import("./mcp.js");
2114
+ getDefaultDevServer();
1954
2115
  const instances = (McpServer as unknown as { _instances: Array<any> })._instances || [];
1955
2116
  for (const s of instances) {
1956
2117
  const tool = ((s as any)._tools as Map<string, any>).get(name);
@@ -1966,6 +2127,59 @@ const handleMcpCall: RouteHandler = async (req, res) => {
1966
2127
  }
1967
2128
  };
1968
2129
 
2130
+ /**
2131
+ * JSON-RPC message endpoint for real MCP clients.
2132
+ *
2133
+ * Mounted at POST /__dev/mcp and POST /__dev/mcp/message. Forwards the request
2134
+ * body to the default dev MCP server's handleMessage() and returns the JSON-RPC
2135
+ * response. Notifications / id-less requests yield an empty 204. Mirrors the
2136
+ * Python v3 fix. The /__dev path is always public (auth-bypassed), so MCP
2137
+ * clients connect without a token.
2138
+ */
2139
+ const handleMcpMessage: RouteHandler = async (req, res) => {
2140
+ try {
2141
+ const { getDefaultDevServer } = await import("./mcp.js");
2142
+ const server = getDefaultDevServer();
2143
+ const body = req.body;
2144
+ let raw: string | Record<string, unknown>;
2145
+ if (typeof body === "object" && body !== null) {
2146
+ raw = body as Record<string, unknown>;
2147
+ } else {
2148
+ raw = typeof body === "string" ? body : String(body ?? "");
2149
+ }
2150
+ const result = await server.handleMessage(raw);
2151
+ if (!result) {
2152
+ // Notification / no id — nothing to return.
2153
+ res.send("", 204);
2154
+ return;
2155
+ }
2156
+ res.json(JSON.parse(result));
2157
+ } catch (e) {
2158
+ res.json({ error: (e as Error).message }, 500);
2159
+ }
2160
+ };
2161
+
2162
+ /**
2163
+ * SSE handshake endpoint for real MCP clients.
2164
+ *
2165
+ * Mounted at GET /__dev/mcp/sse. Announces the JSON-RPC message endpoint via an
2166
+ * `endpoint` event, exactly like the canonical McpServer.registerRoutes() and
2167
+ * the Python v3 fix. Content-Type text/event-stream, status 200.
2168
+ */
2169
+ const handleMcpSse: RouteHandler = async (req, res) => {
2170
+ // req.path is the path only (no query); turn /__dev/mcp/sse into the message
2171
+ // endpoint /__dev/mcp/message that the client should POST to.
2172
+ const reqPath = req.path || "/__dev/mcp/sse";
2173
+ const endpointUrl = reqPath.replace(/\/sse$/, "/message");
2174
+ const sseData = `event: endpoint\ndata: ${endpointUrl}\n\n`;
2175
+ res.raw.writeHead(200, {
2176
+ "Content-Type": "text/event-stream",
2177
+ "Cache-Control": "no-cache",
2178
+ Connection: "keep-alive",
2179
+ });
2180
+ res.raw.end(sseData);
2181
+ };
2182
+
1969
2183
  const handleScaffoldList: RouteHandler = (_req, res) => {
1970
2184
  res.json({
1971
2185
  scaffolds: [
@@ -2322,5 +2536,68 @@ function tina4VersionModal(){
2322
2536
  el.style.color='#f38ba8';
2323
2537
  });
2324
2538
  }
2539
+ </script>
2540
+ <script>
2541
+ (function(){
2542
+ // WebSocket-primary dev reloader. The running server re-imports changed
2543
+ // src/ routes in-process and pushes a {type,file,mtime} message over
2544
+ // /__dev_reload — no respawn, instant refresh. The mtime poll below is a
2545
+ // FALLBACK only, started when the socket is down and stopped on connect.
2546
+ var _t4_css_exts=['.css','.scss'],_t4_debounce=null;
2547
+ var _t4_interval=3000;
2548
+ var _t4_ws=null,_t4_poll_timer=null,_t4_mtime=null;
2549
+ function _t4_apply(d){
2550
+ d=d||{};
2551
+ var f=d.file||'',t=d.type||'';
2552
+ var isCss=t==='css'||_t4_css_exts.some(function(e){return f.endsWith(e)});
2553
+ if(isCss){
2554
+ var links=document.querySelectorAll('link[rel="stylesheet"]');
2555
+ links.forEach(function(l){
2556
+ var href=l.getAttribute('href');
2557
+ if(href){l.setAttribute('href',href.split('?')[0]+'?_t4='+(d.mtime||Date.now()))}
2558
+ });
2559
+ }else{
2560
+ location.reload();
2561
+ }
2562
+ }
2563
+ function _t4_poll(){
2564
+ fetch('/__dev/api/mtime').then(function(r){return r.json()}).then(function(d){
2565
+ // Sentinel: first poll only records the baseline. Use !== (not >) so
2566
+ // the first change after load is not swallowed and a counter reset on
2567
+ // server restart still triggers a reload.
2568
+ if(_t4_mtime===null){_t4_mtime=d.mtime;return;}
2569
+ if(d.mtime!==_t4_mtime){
2570
+ _t4_mtime=d.mtime;
2571
+ if(_t4_debounce)clearTimeout(_t4_debounce);
2572
+ _t4_debounce=setTimeout(function(){_t4_apply(d);},500);
2573
+ }
2574
+ }).catch(function(){});
2575
+ }
2576
+ function _t4_startPoll(){
2577
+ if(_t4_poll_timer)return;
2578
+ _t4_mtime=null;
2579
+ _t4_poll_timer=setInterval(_t4_poll,_t4_interval);
2580
+ }
2581
+ function _t4_stopPoll(){
2582
+ if(_t4_poll_timer){clearInterval(_t4_poll_timer);_t4_poll_timer=null;}
2583
+ }
2584
+ function _t4_connect(){
2585
+ var url=(location.protocol==='https:'?'wss':'ws')+'://'+location.host+'/__dev_reload';
2586
+ try{_t4_ws=new WebSocket(url);}catch(_){_t4_startPoll();return;}
2587
+ _t4_ws.addEventListener('open',function(){_t4_stopPoll();});
2588
+ _t4_ws.addEventListener('message',function(ev){
2589
+ var d=null;
2590
+ try{d=typeof ev.data==='string'?JSON.parse(ev.data):null;}catch(_){}
2591
+ if(!d)return;
2592
+ if(d.type==='reload'||d.type==='change'||d.type==='css'){
2593
+ if(_t4_debounce)clearTimeout(_t4_debounce);
2594
+ _t4_debounce=setTimeout(function(){_t4_apply(d);},150);
2595
+ }
2596
+ });
2597
+ _t4_ws.addEventListener('close',function(){_t4_ws=null;_t4_startPoll();setTimeout(_t4_connect,2000);});
2598
+ _t4_ws.addEventListener('error',function(){try{_t4_ws&&_t4_ws.close();}catch(_){}});
2599
+ }
2600
+ _t4_connect();
2601
+ })();
2325
2602
  </script>`;
2326
2603
  }
@@ -61,6 +61,7 @@ export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled } from "
61
61
  export type { GraphQLField, ResolverFn, GraphQLResult } from "./graphql.js";
62
62
  export {
63
63
  WebSocketServer,
64
+ devReloadWs,
64
65
  computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame,
65
66
  OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
66
67
  CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR,
@@ -121,7 +122,7 @@ export type { WebSocketConnection } from "./websocketConnection.js";
121
122
  export { RedisBackplane, NATSBackplane, createBackplane } from "./websocketBackplane.js";
122
123
  export type { WebSocketBackplane } from "./websocketBackplane.js";
123
124
  export {
124
- McpServer, mcpTool, mcpResource, registerDevTools,
125
+ McpServer, mcpTool, mcpResource, registerDevTools, getDefaultDevServer,
125
126
  encodeResponse, encodeError, encodeNotification, decodeRequest,
126
127
  schemaFromParams, isLocalhost, mcpEnabled, mcpPort,
127
128
  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
  });