@textjs/app 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (335) hide show
  1. package/README.md +34 -2
  2. package/bin/text.js +29 -0
  3. package/bin/text.ts +13 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/cli/build.d.ts +39 -0
  6. package/dist/cli/build.d.ts.map +1 -0
  7. package/dist/cli/build.js +157 -0
  8. package/dist/cli/build.js.map +1 -0
  9. package/dist/cli/config.d.ts +22 -0
  10. package/dist/cli/config.d.ts.map +1 -0
  11. package/dist/cli/config.js +73 -0
  12. package/dist/cli/config.js.map +1 -0
  13. package/dist/cli/dev.d.ts +12 -0
  14. package/dist/cli/dev.d.ts.map +1 -0
  15. package/dist/cli/dev.js +49 -0
  16. package/dist/cli/dev.js.map +1 -0
  17. package/dist/cli/index.d.ts +26 -0
  18. package/dist/cli/index.d.ts.map +1 -0
  19. package/dist/cli/index.js +192 -0
  20. package/dist/cli/index.js.map +1 -0
  21. package/dist/cli/run.d.ts +31 -0
  22. package/dist/cli/run.d.ts.map +1 -0
  23. package/dist/cli/run.js +111 -0
  24. package/dist/cli/run.js.map +1 -0
  25. package/dist/cli/start.d.ts +14 -0
  26. package/dist/cli/start.d.ts.map +1 -0
  27. package/dist/cli/start.js +183 -0
  28. package/dist/cli/start.js.map +1 -0
  29. package/dist/cli/ui.d.ts +35 -0
  30. package/dist/cli/ui.d.ts.map +1 -0
  31. package/dist/cli/ui.js +73 -0
  32. package/dist/cli/ui.js.map +1 -0
  33. package/dist/client.d.ts +34 -0
  34. package/dist/client.d.ts.map +1 -0
  35. package/dist/client.js +93 -0
  36. package/dist/client.js.map +1 -0
  37. package/dist/config.d.ts +143 -0
  38. package/dist/config.d.ts.map +1 -0
  39. package/dist/config.js +28 -0
  40. package/dist/config.js.map +1 -0
  41. package/dist/context.d.ts +44 -0
  42. package/dist/context.d.ts.map +1 -0
  43. package/dist/context.js +26 -0
  44. package/dist/context.js.map +1 -0
  45. package/dist/dev-errors.d.ts +68 -0
  46. package/dist/dev-errors.d.ts.map +1 -0
  47. package/dist/dev-errors.js +121 -0
  48. package/dist/dev-errors.js.map +1 -0
  49. package/dist/devtools.d.ts +64 -0
  50. package/dist/devtools.d.ts.map +1 -0
  51. package/dist/devtools.js +186 -0
  52. package/dist/devtools.js.map +1 -0
  53. package/dist/frame-mirror.d.ts +19 -0
  54. package/dist/frame-mirror.d.ts.map +1 -0
  55. package/dist/frame-mirror.js +141 -0
  56. package/dist/frame-mirror.js.map +1 -0
  57. package/dist/frame-protocol.d.ts +175 -0
  58. package/dist/frame-protocol.d.ts.map +1 -0
  59. package/dist/frame-protocol.js +369 -0
  60. package/dist/frame-protocol.js.map +1 -0
  61. package/dist/history.d.ts +54 -0
  62. package/dist/history.d.ts.map +1 -0
  63. package/dist/history.js +152 -0
  64. package/dist/history.js.map +1 -0
  65. package/dist/index.d.ts +9 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +8 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/link.d.ts +32 -0
  70. package/dist/link.d.ts.map +1 -0
  71. package/dist/link.js +54 -0
  72. package/dist/link.js.map +1 -0
  73. package/dist/live-player.d.ts +83 -0
  74. package/dist/live-player.d.ts.map +1 -0
  75. package/dist/live-player.js +413 -0
  76. package/dist/live-player.js.map +1 -0
  77. package/dist/live-protocol.d.ts +132 -0
  78. package/dist/live-protocol.d.ts.map +1 -0
  79. package/dist/live-protocol.js +278 -0
  80. package/dist/live-protocol.js.map +1 -0
  81. package/dist/live-stub.d.ts +10 -0
  82. package/dist/live-stub.d.ts.map +1 -0
  83. package/dist/live-stub.js +32 -0
  84. package/dist/live-stub.js.map +1 -0
  85. package/dist/mcp/cli.d.ts +22 -0
  86. package/dist/mcp/cli.d.ts.map +1 -0
  87. package/dist/mcp/cli.js +177 -0
  88. package/dist/mcp/cli.js.map +1 -0
  89. package/dist/mcp/frame.d.ts +58 -0
  90. package/dist/mcp/frame.d.ts.map +1 -0
  91. package/dist/mcp/frame.js +169 -0
  92. package/dist/mcp/frame.js.map +1 -0
  93. package/dist/mcp/index.d.ts +15 -0
  94. package/dist/mcp/index.d.ts.map +1 -0
  95. package/dist/mcp/index.js +15 -0
  96. package/dist/mcp/index.js.map +1 -0
  97. package/dist/mcp/inspect.d.ts +95 -0
  98. package/dist/mcp/inspect.d.ts.map +1 -0
  99. package/dist/mcp/inspect.js +386 -0
  100. package/dist/mcp/inspect.js.map +1 -0
  101. package/dist/mcp/keys.d.ts +20 -0
  102. package/dist/mcp/keys.d.ts.map +1 -0
  103. package/dist/mcp/keys.js +87 -0
  104. package/dist/mcp/keys.js.map +1 -0
  105. package/dist/mcp/logs.d.ts +63 -0
  106. package/dist/mcp/logs.d.ts.map +1 -0
  107. package/dist/mcp/logs.js +217 -0
  108. package/dist/mcp/logs.js.map +1 -0
  109. package/dist/mcp/server.d.ts +64 -0
  110. package/dist/mcp/server.d.ts.map +1 -0
  111. package/dist/mcp/server.js +485 -0
  112. package/dist/mcp/server.js.map +1 -0
  113. package/dist/mcp/session.d.ts +230 -0
  114. package/dist/mcp/session.d.ts.map +1 -0
  115. package/dist/mcp/session.js +742 -0
  116. package/dist/mcp/session.js.map +1 -0
  117. package/dist/navigation-errors.d.ts +28 -0
  118. package/dist/navigation-errors.d.ts.map +1 -0
  119. package/dist/navigation-errors.js +43 -0
  120. package/dist/navigation-errors.js.map +1 -0
  121. package/dist/navigation.d.ts +21 -0
  122. package/dist/navigation.d.ts.map +1 -0
  123. package/dist/navigation.js +33 -0
  124. package/dist/navigation.js.map +1 -0
  125. package/dist/router.d.ts +76 -0
  126. package/dist/router.d.ts.map +1 -0
  127. package/dist/router.js +296 -0
  128. package/dist/router.js.map +1 -0
  129. package/dist/routing.d.ts +101 -0
  130. package/dist/routing.d.ts.map +1 -0
  131. package/dist/routing.js +286 -0
  132. package/dist/routing.js.map +1 -0
  133. package/dist/server/adapters/build-step.d.ts +34 -0
  134. package/dist/server/adapters/build-step.d.ts.map +1 -0
  135. package/dist/server/adapters/build-step.js +112 -0
  136. package/dist/server/adapters/build-step.js.map +1 -0
  137. package/dist/server/adapters/bun.d.ts +8 -0
  138. package/dist/server/adapters/bun.d.ts.map +1 -0
  139. package/dist/server/adapters/bun.js +89 -0
  140. package/dist/server/adapters/bun.js.map +1 -0
  141. package/dist/server/adapters/bundle.d.ts +21 -0
  142. package/dist/server/adapters/bundle.d.ts.map +1 -0
  143. package/dist/server/adapters/bundle.js +86 -0
  144. package/dist/server/adapters/bundle.js.map +1 -0
  145. package/dist/server/adapters/cloudflare.d.ts +29 -0
  146. package/dist/server/adapters/cloudflare.d.ts.map +1 -0
  147. package/dist/server/adapters/cloudflare.js +105 -0
  148. package/dist/server/adapters/cloudflare.js.map +1 -0
  149. package/dist/server/adapters/index.d.ts +10 -0
  150. package/dist/server/adapters/index.d.ts.map +1 -0
  151. package/dist/server/adapters/index.js +24 -0
  152. package/dist/server/adapters/index.js.map +1 -0
  153. package/dist/server/adapters/runtime-node.d.ts +16 -0
  154. package/dist/server/adapters/runtime-node.d.ts.map +1 -0
  155. package/dist/server/adapters/runtime-node.js +29 -0
  156. package/dist/server/adapters/runtime-node.js.map +1 -0
  157. package/dist/server/adapters/runtime.d.ts +42 -0
  158. package/dist/server/adapters/runtime.d.ts.map +1 -0
  159. package/dist/server/adapters/runtime.js +115 -0
  160. package/dist/server/adapters/runtime.js.map +1 -0
  161. package/dist/server/adapters/shared.d.ts +66 -0
  162. package/dist/server/adapters/shared.d.ts.map +1 -0
  163. package/dist/server/adapters/shared.js +124 -0
  164. package/dist/server/adapters/shared.js.map +1 -0
  165. package/dist/server/adapters/static.d.ts +9 -0
  166. package/dist/server/adapters/static.d.ts.map +1 -0
  167. package/dist/server/adapters/static.js +70 -0
  168. package/dist/server/adapters/static.js.map +1 -0
  169. package/dist/server/adapters/vercel.d.ts +43 -0
  170. package/dist/server/adapters/vercel.d.ts.map +1 -0
  171. package/dist/server/adapters/vercel.js +119 -0
  172. package/dist/server/adapters/vercel.js.map +1 -0
  173. package/dist/server/bun-jsx.d.ts +18 -0
  174. package/dist/server/bun-jsx.d.ts.map +1 -0
  175. package/dist/server/bun-jsx.js +56 -0
  176. package/dist/server/bun-jsx.js.map +1 -0
  177. package/dist/server/cache-route.d.ts +38 -0
  178. package/dist/server/cache-route.d.ts.map +1 -0
  179. package/dist/server/cache-route.js +109 -0
  180. package/dist/server/cache-route.js.map +1 -0
  181. package/dist/server/cache.d.ts +131 -0
  182. package/dist/server/cache.d.ts.map +1 -0
  183. package/dist/server/cache.js +409 -0
  184. package/dist/server/cache.js.map +1 -0
  185. package/dist/server/frames.d.ts +45 -0
  186. package/dist/server/frames.d.ts.map +1 -0
  187. package/dist/server/frames.js +294 -0
  188. package/dist/server/frames.js.map +1 -0
  189. package/dist/server/http.d.ts +80 -0
  190. package/dist/server/http.d.ts.map +1 -0
  191. package/dist/server/http.js +453 -0
  192. package/dist/server/http.js.map +1 -0
  193. package/dist/server/jsx-dev-runtime.d.ts +5 -0
  194. package/dist/server/jsx-dev-runtime.d.ts.map +1 -0
  195. package/dist/server/jsx-dev-runtime.js +6 -0
  196. package/dist/server/jsx-dev-runtime.js.map +1 -0
  197. package/dist/server/jsx-runtime.d.ts +10 -0
  198. package/dist/server/jsx-runtime.d.ts.map +1 -0
  199. package/dist/server/jsx-runtime.js +11 -0
  200. package/dist/server/jsx-runtime.js.map +1 -0
  201. package/dist/server/live-app.d.ts +87 -0
  202. package/dist/server/live-app.d.ts.map +1 -0
  203. package/dist/server/live-app.js +307 -0
  204. package/dist/server/live-app.js.map +1 -0
  205. package/dist/server/live-build.d.ts +70 -0
  206. package/dist/server/live-build.d.ts.map +1 -0
  207. package/dist/server/live-build.js +222 -0
  208. package/dist/server/live-build.js.map +1 -0
  209. package/dist/server/live-dev.d.ts +62 -0
  210. package/dist/server/live-dev.d.ts.map +1 -0
  211. package/dist/server/live-dev.js +313 -0
  212. package/dist/server/live-dev.js.map +1 -0
  213. package/dist/server/live-manifest.d.ts +23 -0
  214. package/dist/server/live-manifest.d.ts.map +1 -0
  215. package/dist/server/live-manifest.js +7 -0
  216. package/dist/server/live-manifest.js.map +1 -0
  217. package/dist/server/live-prod.d.ts +30 -0
  218. package/dist/server/live-prod.d.ts.map +1 -0
  219. package/dist/server/live-prod.js +97 -0
  220. package/dist/server/live-prod.js.map +1 -0
  221. package/dist/server/live-routes.d.ts +72 -0
  222. package/dist/server/live-routes.d.ts.map +1 -0
  223. package/dist/server/live-routes.js +274 -0
  224. package/dist/server/live-routes.js.map +1 -0
  225. package/dist/server/live-web.d.ts +16 -0
  226. package/dist/server/live-web.d.ts.map +1 -0
  227. package/dist/server/live-web.js +159 -0
  228. package/dist/server/live-web.js.map +1 -0
  229. package/dist/server/live.d.ts +138 -0
  230. package/dist/server/live.d.ts.map +1 -0
  231. package/dist/server/live.js +542 -0
  232. package/dist/server/live.js.map +1 -0
  233. package/dist/server/middleware-node.d.ts +48 -0
  234. package/dist/server/middleware-node.d.ts.map +1 -0
  235. package/dist/server/middleware-node.js +167 -0
  236. package/dist/server/middleware-node.js.map +1 -0
  237. package/dist/server/middleware-runtime.d.ts +97 -0
  238. package/dist/server/middleware-runtime.d.ts.map +1 -0
  239. package/dist/server/middleware-runtime.js +447 -0
  240. package/dist/server/middleware-runtime.js.map +1 -0
  241. package/dist/server/middleware.d.ts +145 -0
  242. package/dist/server/middleware.d.ts.map +1 -0
  243. package/dist/server/middleware.js +365 -0
  244. package/dist/server/middleware.js.map +1 -0
  245. package/dist/server/ppr.d.ts +22 -0
  246. package/dist/server/ppr.d.ts.map +1 -0
  247. package/dist/server/ppr.js +58 -0
  248. package/dist/server/ppr.js.map +1 -0
  249. package/dist/server/scan.d.ts +48 -0
  250. package/dist/server/scan.d.ts.map +1 -0
  251. package/dist/server/scan.js +161 -0
  252. package/dist/server/scan.js.map +1 -0
  253. package/dist/server/server-components.d.ts +65 -0
  254. package/dist/server/server-components.d.ts.map +1 -0
  255. package/dist/server/server-components.js +215 -0
  256. package/dist/server/server-components.js.map +1 -0
  257. package/dist/terminal.d.ts +5 -0
  258. package/dist/terminal.d.ts.map +1 -0
  259. package/dist/terminal.js +5 -0
  260. package/dist/terminal.js.map +1 -0
  261. package/dist/testing.d.ts +5 -0
  262. package/dist/testing.d.ts.map +1 -0
  263. package/dist/testing.js +5 -0
  264. package/dist/testing.js.map +1 -0
  265. package/dist/vite-plugin.d.ts +51 -0
  266. package/dist/vite-plugin.d.ts.map +1 -0
  267. package/dist/vite-plugin.js +403 -0
  268. package/dist/vite-plugin.js.map +1 -0
  269. package/package.json +130 -4
  270. package/src/cli/build.ts +182 -0
  271. package/src/cli/config.ts +72 -0
  272. package/src/cli/dev.ts +54 -0
  273. package/src/cli/index.ts +185 -0
  274. package/src/cli/run.ts +124 -0
  275. package/src/cli/start.ts +194 -0
  276. package/src/cli/ui.ts +93 -0
  277. package/src/client.tsx +122 -0
  278. package/src/config.ts +155 -0
  279. package/src/context.ts +69 -0
  280. package/src/dev-errors.ts +167 -0
  281. package/src/devtools.tsx +419 -0
  282. package/src/frame-mirror.ts +145 -0
  283. package/src/frame-protocol.ts +435 -0
  284. package/src/history.ts +201 -0
  285. package/src/index.ts +9 -0
  286. package/src/link.tsx +108 -0
  287. package/src/live-player.ts +449 -0
  288. package/src/live-protocol.ts +317 -0
  289. package/src/live-stub.tsx +32 -0
  290. package/src/mcp/cli.ts +188 -0
  291. package/src/mcp/frame.ts +224 -0
  292. package/src/mcp/index.ts +15 -0
  293. package/src/mcp/inspect.ts +445 -0
  294. package/src/mcp/keys.ts +86 -0
  295. package/src/mcp/logs.ts +225 -0
  296. package/src/mcp/server.ts +595 -0
  297. package/src/mcp/session.ts +821 -0
  298. package/src/navigation-errors.ts +60 -0
  299. package/src/navigation.ts +44 -0
  300. package/src/router.tsx +497 -0
  301. package/src/routing.ts +344 -0
  302. package/src/server/adapters/build-step.ts +135 -0
  303. package/src/server/adapters/bun.ts +92 -0
  304. package/src/server/adapters/bundle.ts +97 -0
  305. package/src/server/adapters/cloudflare.ts +111 -0
  306. package/src/server/adapters/index.ts +29 -0
  307. package/src/server/adapters/runtime-node.ts +34 -0
  308. package/src/server/adapters/runtime.ts +145 -0
  309. package/src/server/adapters/shared.ts +159 -0
  310. package/src/server/adapters/static.ts +68 -0
  311. package/src/server/adapters/vercel.ts +149 -0
  312. package/src/server/bun-jsx.ts +65 -0
  313. package/src/server/cache-route.ts +139 -0
  314. package/src/server/cache.ts +498 -0
  315. package/src/server/frames.ts +326 -0
  316. package/src/server/http.ts +467 -0
  317. package/src/server/jsx-dev-runtime.ts +9 -0
  318. package/src/server/jsx-runtime.ts +13 -0
  319. package/src/server/live-app.tsx +374 -0
  320. package/src/server/live-build.ts +258 -0
  321. package/src/server/live-dev.ts +357 -0
  322. package/src/server/live-manifest.ts +25 -0
  323. package/src/server/live-prod.ts +120 -0
  324. package/src/server/live-routes.ts +298 -0
  325. package/src/server/live-web.ts +170 -0
  326. package/src/server/live.ts +551 -0
  327. package/src/server/middleware-node.ts +168 -0
  328. package/src/server/middleware-runtime.ts +478 -0
  329. package/src/server/middleware.ts +451 -0
  330. package/src/server/ppr.ts +62 -0
  331. package/src/server/scan.ts +190 -0
  332. package/src/server/server-components.ts +224 -0
  333. package/src/terminal.ts +5 -0
  334. package/src/testing.ts +5 -0
  335. package/src/vite-plugin.ts +413 -0
@@ -0,0 +1,595 @@
1
+ /**
2
+ * The `text mcp` tool surface: an MCP server whose tools drive a headless
3
+ * Text.js app session (see `session.ts`).
4
+ */
5
+
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
8
+ import { z } from "zod";
9
+ import { VERSION } from "@textjs/core";
10
+ import { findText, type StyledRow } from "./frame";
11
+ import { createHitTester, formatTree } from "./inspect";
12
+ import { KeySpecError, MAX_KEYS_PER_CALL } from "./keys";
13
+ import { captureConsole, LogBuffer, txCodeOf, type ConsoleCapture, type LogLevel } from "./logs";
14
+ import { HeadlessSession, SessionError, type FrameSnapshot, type SessionOptions } from "./session";
15
+
16
+ export interface TextMcpServerOptions extends SessionOptions {
17
+ /** Server name reported to clients (default `"textjs"`). */
18
+ name?: string;
19
+ /**
20
+ * Route `console.*` into the `logs` tool while the server runs (default
21
+ * `true`). Off when the caller already captures (the `text mcp` CLI does,
22
+ * before anything else loads).
23
+ */
24
+ captureConsole?: boolean;
25
+ }
26
+
27
+ const INSTRUCTIONS = `This server runs a Text.js app (React rendered into a character grid) headlessly, in-process.
28
+ Workflow: list_routes → navigate → read_frame. Drive the UI with press (key combos such as "down", "ctrl+k", "tab tab enter", "down*3"), type, paste and scroll (mouse wheel). Input tools wait until the app is idle and return the new frame, so a separate read_frame is rarely needed.
29
+ Coordinates are zero-based cells: x = column, y = row. Use find to locate text, inspect_tree for the component tree with bounds and focus ids, wait_for for async UI, fetch to call app/**/route.ts handlers, logs for console output and React errors, and reload after editing files.`;
30
+
31
+ /** Mutex: tool calls run one at a time so input order is deterministic. */
32
+ export class Lock {
33
+ private tail: Promise<void> = Promise.resolve();
34
+
35
+ /** Wait for every earlier holder to release, then hold the lock until `release()` is called. */
36
+ async acquire(): Promise<() => void> {
37
+ let release!: () => void;
38
+ const held = new Promise<void>((r) => {
39
+ release = r;
40
+ });
41
+ const prev = this.tail;
42
+ this.tail = prev.then(() => held);
43
+ await prev;
44
+ return release;
45
+ }
46
+ }
47
+
48
+ export class TimeoutError extends Error {}
49
+
50
+ /**
51
+ * Race `work` against `ms`. On timeout the caller gets a {@link TimeoutError}
52
+ * at once and `controller` is aborted so cancellable work stops.
53
+ */
54
+ export function withTimeout<T>(work: Promise<T>, ms: number, label: string, controller?: AbortController): Promise<T> {
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ const timeout = new Promise<never>((_, reject) => {
57
+ timer = setTimeout(() => {
58
+ const error = new TimeoutError(`${label} timed out after ${ms}ms.`);
59
+ controller?.abort(error);
60
+ reject(error);
61
+ }, ms);
62
+ });
63
+ return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
64
+ }
65
+
66
+ /** Owns the session lifecycle: the app loads in the background so the MCP handshake never waits on it. */
67
+ export class SessionController {
68
+ private session: HeadlessSession | null = null;
69
+ private starting: Promise<HeadlessSession> | null = null;
70
+ private capture: ConsoleCapture | null = null;
71
+ readonly logs: LogBuffer;
72
+
73
+ constructor(
74
+ private readonly options: SessionOptions,
75
+ private readonly shouldCapture = true,
76
+ ) {
77
+ this.logs = options.logs ?? new LogBuffer();
78
+ }
79
+
80
+ /** Start loading the app (idempotent). */
81
+ start(): Promise<HeadlessSession> {
82
+ if (this.session) return Promise.resolve(this.session);
83
+ if (this.shouldCapture && !this.capture) this.capture = captureConsole(this.logs);
84
+ if (!this.starting) {
85
+ this.starting = HeadlessSession.start({ ...this.options, logs: this.logs }).then(
86
+ (s) => {
87
+ this.session = s;
88
+ return s;
89
+ },
90
+ (error: unknown) => {
91
+ this.starting = null;
92
+ this.logs.push("error", "server", ["Failed to start the app:", error]);
93
+ throw error;
94
+ },
95
+ );
96
+ }
97
+ return this.starting;
98
+ }
99
+
100
+ get(): Promise<HeadlessSession> {
101
+ return this.start();
102
+ }
103
+
104
+ /** Reload the running session, or retry a failed start. */
105
+ async reload(): Promise<{ session: HeadlessSession; purged: number }> {
106
+ const s = this.session;
107
+ if (s) return { session: s, purged: (await s.reload()).purged };
108
+ return { session: await this.start(), purged: 0 };
109
+ }
110
+
111
+ async dispose(): Promise<void> {
112
+ const s = this.session ?? (await this.starting?.catch(() => null)) ?? null;
113
+ this.session = null;
114
+ this.starting = null;
115
+ await s?.dispose();
116
+ this.capture?.restore();
117
+ this.capture = null;
118
+ }
119
+ }
120
+
121
+ function errorText(error: unknown): string {
122
+ const message = error instanceof Error ? error.message : String(error);
123
+ const code = (error as { code?: unknown } | null)?.code;
124
+ const tx = typeof code === "string" && /^TX\d{3}$/.test(code) ? code : txCodeOf(error);
125
+ if (tx && !message.includes(tx)) return `Text.js error ${tx}: ${message} https://textjs.org/errors/${tx}`;
126
+ return message;
127
+ }
128
+
129
+ function toolError(error: unknown): CallToolResult {
130
+ const code = txCodeOf(error) ?? ((error as { code?: unknown } | null)?.code as string | undefined);
131
+ const kind = error instanceof TimeoutError ? "timeout" : error instanceof KeySpecError ? "invalid_keys" : error instanceof SessionError ? "session" : "error";
132
+ return {
133
+ isError: true,
134
+ content: [{ type: "text", text: errorText(error) }],
135
+ structuredContent: { error: { kind, message: error instanceof Error ? error.message : String(error), ...(typeof code === "string" ? { code } : {}) } },
136
+ };
137
+ }
138
+
139
+ function formatBounds(b: { x: number; y: number; width: number; height: number }): string {
140
+ return `[${b.x},${b.y} ${b.width}×${b.height}]`;
141
+ }
142
+
143
+ const STATUS_HINTS: Record<Exclude<FrameSnapshot["status"], "running">, string> = {
144
+ exited: "the app called exit(); call reload to restart",
145
+ crashed: "uncaught error; read logs, fix it, then call reload",
146
+ failed: "the app failed to load; read logs, fix it, then call reload",
147
+ };
148
+
149
+ /** Header lines + the frame text, the default content of every frame-returning tool. */
150
+ export function formatFrame(f: FrameSnapshot): string {
151
+ const lines: string[] = [];
152
+ const route = f.route === f.href ? f.href : `${f.href} (route ${f.route})`;
153
+ lines.push(`route: ${route}${f.title ? ` title: ${JSON.stringify(f.title)}` : ""}`);
154
+ lines.push(`size: ${f.cols}×${f.rows} frame: ${f.frame} ${f.idle ? "idle" : "busy"}`);
155
+ if (f.region) lines.push(`region: ${formatBounds(f.region)}`);
156
+ if (f.focus) lines.push(`focus: ${f.focus.id}${f.focus.component ? ` ${f.focus.component}` : ""}${f.focus.bounds ? ` ${formatBounds(f.focus.bounds)}` : ""}${f.cursor ? ` cursor: ${f.cursor.x},${f.cursor.y}` : ""}`);
157
+ else lines.push("focus: none");
158
+ if (f.status !== "running") lines.push(`status: ${f.status} (${STATUS_HINTS[f.status]})`);
159
+ if (f.newErrors > 0) lines.push(`errors: ${f.newErrors} new (call logs with level ["error"])`);
160
+ lines.push("");
161
+ lines.push(f.text);
162
+ return lines.join("\n");
163
+ }
164
+
165
+ /**
166
+ * Styled runs as compact lines for the text content (the JSON lives once, in
167
+ * `structuredContent.styles`): `y x+width fg bg attrs "text"`, skipping
168
+ * blank default-styled runs.
169
+ */
170
+ export function formatStyles(rows: readonly StyledRow[]): string {
171
+ const lines = ["styles (row col+width fg bg attrs \"text\"):"];
172
+ for (const row of rows) {
173
+ for (const r of row.runs) {
174
+ if (r.bg === null && r.attrs === undefined && r.text.trim() === "") continue;
175
+ lines.push(`${row.y} ${r.x}+${r.width} ${r.fg ?? "-"} ${r.bg ?? "-"} ${r.attrs?.join(",") || "-"} ${JSON.stringify(r.text)}`);
176
+ }
177
+ }
178
+ return lines.join("\n");
179
+ }
180
+
181
+ function frameResult(f: FrameSnapshot, extra: Record<string, unknown> = {}, prefix?: string): CallToolResult {
182
+ const { styles, ...rest } = f;
183
+ let text = formatFrame(f);
184
+ if (prefix) text = `${prefix}\n${text}`;
185
+ const content: CallToolResult["content"] = [{ type: "text", text }];
186
+ if (styles) content.push({ type: "text", text: formatStyles(styles) });
187
+ return { content, structuredContent: { ...extra, ...rest, ...(styles ? { styles } : {}) } };
188
+ }
189
+
190
+ const regionSchema = z
191
+ .object({
192
+ x: z.number().int().min(0),
193
+ y: z.number().int().min(0),
194
+ width: z.number().int().min(1),
195
+ height: z.number().int().min(1),
196
+ })
197
+ .describe("Cell rectangle to read (zero-based). Default: the whole frame.");
198
+
199
+ /** `type` sends one key event per grapheme; longer input belongs in `paste`. */
200
+ const MAX_TYPE_CHARS = 2000;
201
+
202
+ const settleSchema = z.number().int().min(0).max(30_000).optional().describe("Max ms to wait for the app to go idle before reading (default 1500). 0 reads immediately.");
203
+
204
+ /** Create the MCP server. Call `controller.start()` (or any tool) to load the app. */
205
+ export function createTextMcpServer(options: TextMcpServerOptions): { server: McpServer; controller: SessionController } {
206
+ const controller = new SessionController(options, options.captureConsole ?? true);
207
+ const server = new McpServer(
208
+ { name: options.name ?? "textjs", title: "▼ Text.js", version: VERSION },
209
+ { instructions: INSTRUCTIONS, capabilities: { tools: {}, resources: {}, logging: {} } },
210
+ );
211
+ const lock = new Lock();
212
+
213
+ /**
214
+ * Run `work` under the lock with a hard timeout; any throw becomes a tool
215
+ * error. A timed-out call returns at once and its work is aborted, but the
216
+ * lock stays held until the work has actually stopped (a remount in
217
+ * `reload` / start is not cancellable), so it never interleaves with the
218
+ * next call.
219
+ */
220
+ const locked = async (ms: number, label: string, work: (signal: AbortSignal) => Promise<CallToolResult>): Promise<CallToolResult> => {
221
+ const release = await lock.acquire();
222
+ const ac = new AbortController();
223
+ const running = (async () => work(ac.signal))();
224
+ running.then(release, release);
225
+ try {
226
+ return await withTimeout(running, ms, label, ac);
227
+ } catch (error) {
228
+ return toolError(error);
229
+ }
230
+ };
231
+
232
+ const tool = <A>(timeoutMs: number | ((args: A) => number), label: string, body: (s: HeadlessSession, args: A, signal: AbortSignal) => Promise<CallToolResult>) => {
233
+ return (args: A): Promise<CallToolResult> =>
234
+ locked(typeof timeoutMs === "function" ? timeoutMs(args) : timeoutMs, label, async (signal) => {
235
+ const s = await controller.get();
236
+ if (signal.aborted) throw signal.reason;
237
+ return body(s, args, signal);
238
+ });
239
+ };
240
+
241
+ const settleThen = async (s: HeadlessSession, settle: number | undefined, read: { styles?: boolean } = {}, signal?: AbortSignal): Promise<FrameSnapshot> => {
242
+ if (settle !== 0) await s.settle({ timeout: settle ?? 1500, signal });
243
+ return s.frame(read);
244
+ };
245
+
246
+ server.registerTool(
247
+ "list_routes",
248
+ {
249
+ title: "List routes",
250
+ description: "List the app's pages (app/**/page.tsx) and route handlers (app/**/route.ts), plus the current location.",
251
+ inputSchema: {},
252
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
253
+ },
254
+ tool<Record<string, never>>(10_000, "list_routes", async (s) => {
255
+ const r = s.routes();
256
+ const lines = [`current: ${r.current}`, "", "pages:"];
257
+ for (const p of r.pages) lines.push(` ${p.dynamic ? "ƒ" : "○"} ${p.pattern}${p.file ? ` ${p.file}` : ""}`);
258
+ lines.push("", "route handlers:");
259
+ if (r.api.length === 0) lines.push(" (none)");
260
+ for (const a of r.api) lines.push(` ${a.pattern} ${a.file}`);
261
+ return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: r as unknown as Record<string, unknown> };
262
+ }),
263
+ );
264
+
265
+ server.registerTool(
266
+ "navigate",
267
+ {
268
+ title: "Navigate",
269
+ description: "Navigate the app router (memory history) and return the settled frame. Pass `path` (e.g. \"/deployments/dpl_1?tab=logs\"), or `history: \"back\" | \"forward\"`.",
270
+ inputSchema: {
271
+ path: z.string().min(1).max(2048).optional().describe("App path to push, starting with /."),
272
+ replace: z.boolean().optional().describe("Replace the current history entry instead of pushing."),
273
+ history: z.enum(["back", "forward"]).optional(),
274
+ settleMs: settleSchema,
275
+ },
276
+ annotations: { readOnlyHint: false, openWorldHint: false },
277
+ },
278
+ tool<{ path?: string; replace?: boolean; history?: "back" | "forward"; settleMs?: number }>(35_000, "navigate", async (s, a, signal) => {
279
+ if (a.history) s.navigate(a.history);
280
+ else if (a.path !== undefined) {
281
+ if (!a.path.startsWith("/")) throw new SessionError(`Paths must start with "/" (got ${JSON.stringify(a.path)}).`);
282
+ s.navigate({ path: a.path, replace: a.replace });
283
+ } else throw new SessionError("Pass `path` or `history`.");
284
+ return frameResult(await settleThen(s, a.settleMs, {}, signal));
285
+ }),
286
+ );
287
+
288
+ server.registerTool(
289
+ "read_frame",
290
+ {
291
+ title: "Read frame",
292
+ description:
293
+ "Return the current frame as plain text, with route, size, focus and cursor. Waits for the app to go idle first. `styles: true` adds run-length encoded styled spans per row (fg/bg hex colors, bold/dim/italic/underline/strikethrough/inverse).",
294
+ inputSchema: {
295
+ styles: z.boolean().optional(),
296
+ trim: z.boolean().optional().describe("Trim trailing spaces and blank rows (default true). Set false for a rectangular cell-grid snapshot."),
297
+ region: regionSchema.optional(),
298
+ settleMs: settleSchema,
299
+ },
300
+ annotations: { readOnlyHint: true, openWorldHint: false },
301
+ },
302
+ tool<{ styles?: boolean; trim?: boolean; region?: { x: number; y: number; width: number; height: number }; settleMs?: number }>(35_000, "read_frame", async (s, a, signal) => {
303
+ if (a.settleMs !== 0) await s.settle({ timeout: a.settleMs ?? 1500, signal });
304
+ return frameResult(s.frame({ styles: a.styles, region: a.region, trim: a.trim }));
305
+ }),
306
+ );
307
+
308
+ server.registerTool(
309
+ "press",
310
+ {
311
+ title: "Press keys",
312
+ description: `Press keys in order, then return the settled frame. Combos: "down", "enter", "escape", "tab", "shift+tab", "ctrl+k", "alt+x", "G" (shift+g), "?", "f2", "pageup", "space". Space-separated combos are a sequence ("down down enter"); "down*3" repeats. Max ${MAX_KEYS_PER_CALL} keys per call. All combos are validated before any key is sent.`,
313
+ inputSchema: {
314
+ keys: z.union([z.string().min(1).max(4000), z.array(z.string().min(1).max(200)).min(1).max(MAX_KEYS_PER_CALL)]),
315
+ settleMs: settleSchema,
316
+ },
317
+ annotations: { readOnlyHint: false, openWorldHint: false },
318
+ },
319
+ tool<{ keys: string | string[]; settleMs?: number }>(60_000, "press", async (s, a, signal) => {
320
+ const n = await s.press(a.keys, signal);
321
+ return frameResult(await settleThen(s, a.settleMs, {}, signal), { pressed: n }, `pressed ${n} key${n === 1 ? "" : "s"}`);
322
+ }),
323
+ );
324
+
325
+ server.registerTool(
326
+ "type",
327
+ {
328
+ title: "Type text",
329
+ description: `Type text one key at a time into the focused component (\\n presses enter, \\t presses tab), then return the settled frame. \`submit: true\` presses enter afterwards. Up to ${MAX_TYPE_CHARS} characters; use paste for longer text.`,
330
+ inputSchema: {
331
+ text: z.string().max(MAX_TYPE_CHARS, `type sends one key per character (max ${MAX_TYPE_CHARS}); use paste for longer text.`),
332
+ submit: z.boolean().optional(),
333
+ settleMs: settleSchema,
334
+ },
335
+ annotations: { readOnlyHint: false, openWorldHint: false },
336
+ },
337
+ tool<{ text: string; submit?: boolean; settleMs?: number }>(60_000, "type", async (s, a, signal) => {
338
+ const n = await s.type(a.text, signal);
339
+ if (a.submit) await s.press("return", signal);
340
+ return frameResult(await settleThen(s, a.settleMs, {}, signal), { typed: n });
341
+ }),
342
+ );
343
+
344
+ server.registerTool(
345
+ "paste",
346
+ {
347
+ title: "Paste text",
348
+ description: "Deliver text as one paste event (like ⌘V / bracketed paste), then return the settled frame.",
349
+ inputSchema: { text: z.string().max(1_000_000), settleMs: settleSchema },
350
+ annotations: { readOnlyHint: false, openWorldHint: false },
351
+ },
352
+ tool<{ text: string; settleMs?: number }>(35_000, "paste", async (s, a, signal) => {
353
+ await s.paste(a.text, signal);
354
+ return frameResult(await settleThen(s, a.settleMs, {}, signal));
355
+ }),
356
+ );
357
+
358
+ server.registerTool(
359
+ "scroll",
360
+ {
361
+ title: "Scroll",
362
+ description:
363
+ "Scroll with the mouse wheel by `rows` (positive = down, negative = up) and optionally `cols`, then return the settled frame. <ScrollBox> scrolls on the wheel even when it has no keyboard focus. Wheel input is not positional: the focused ScrollBox gets it first, otherwise every wheel-enabled one.",
364
+ inputSchema: {
365
+ rows: z.number().int().min(-1000).max(1000).describe("Rows to scroll; positive scrolls down."),
366
+ cols: z.number().int().min(-1000).max(1000).optional().describe("Columns to scroll; positive scrolls right."),
367
+ settleMs: settleSchema,
368
+ },
369
+ annotations: { readOnlyHint: false, openWorldHint: false },
370
+ },
371
+ tool<{ rows: number; cols?: number; settleMs?: number }>(35_000, "scroll", async (s, a, signal) => {
372
+ const n = await s.scroll(a.rows, a.cols ?? 0, signal);
373
+ return frameResult(await settleThen(s, a.settleMs, {}, signal), { scrolled: n });
374
+ }),
375
+ );
376
+
377
+ server.registerTool(
378
+ "resize",
379
+ {
380
+ title: "Resize",
381
+ description: "Resize the grid (columns × rows; clamped to 10–1000 × 3–500) and return the settled frame.",
382
+ inputSchema: { cols: z.number().int().min(1).max(1000), rows: z.number().int().min(1).max(500), settleMs: settleSchema },
383
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
384
+ },
385
+ tool<{ cols: number; rows: number; settleMs?: number }>(35_000, "resize", async (s, a, signal) => {
386
+ s.resize(a.cols, a.rows);
387
+ return frameResult(await settleThen(s, a.settleMs, {}, signal));
388
+ }),
389
+ );
390
+
391
+ server.registerTool(
392
+ "inspect_tree",
393
+ {
394
+ title: "Inspect tree",
395
+ description:
396
+ "The React component tree interleaved with scene nodes (box / text) and their screen bounds [x,y w×h], plus every focusable id (useFocus) with its component, bounds and Tab eligibility. Use `component` to return only matching subtrees and `hosts: false` for components only.",
397
+ inputSchema: {
398
+ component: z.string().max(200).optional().describe("Case-insensitive component name filter."),
399
+ hosts: z.boolean().optional().describe("Include box/text scene nodes (default true)."),
400
+ maxDepth: z.number().int().min(1).max(200).optional().describe("Levels to return (default 40), counted from each match when `component` is set."),
401
+ format: z.enum(["text", "json"]).optional().describe("text (indented, default) or json."),
402
+ },
403
+ annotations: { readOnlyHint: true, openWorldHint: false },
404
+ },
405
+ tool<{ component?: string; hosts?: boolean; maxDepth?: number; format?: "text" | "json" }>(35_000, "inspect_tree", async (s, a, signal) => {
406
+ await s.settle({ signal });
407
+ const r = s.inspect({ component: a.component, hosts: a.hosts, maxDepth: a.maxDepth });
408
+ const structured = { tree: r.tree, focusables: r.focusables, activeFocusId: r.activeFocusId };
409
+ if (a.format === "json") return { content: [{ type: "text", text: JSON.stringify(structured) }], structuredContent: structured as unknown as Record<string, unknown> };
410
+ const lines = [r.tree ? formatTree(r.tree) : "(tree unavailable)", "", "focusables:"];
411
+ if (r.focusables.length === 0) lines.push(" (none)");
412
+ for (const f of r.focusables) {
413
+ lines.push(` ${f.focused ? "▸" : " "} ${f.id}${f.component ? ` ${f.component}` : ""}${f.bounds ? ` ${formatBounds(f.bounds)}` : ""}${f.tabbable ? "" : " (not tabbable)"}`);
414
+ }
415
+ return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: structured as unknown as Record<string, unknown> };
416
+ }),
417
+ );
418
+
419
+ server.registerTool(
420
+ "find",
421
+ {
422
+ title: "Find text",
423
+ description: "Locate text in the current frame. Returns each match's cell bounds, its row, and the component under it.",
424
+ inputSchema: {
425
+ text: z.string().min(1).max(1000),
426
+ regex: z.boolean().optional().describe("Treat `text` as a JavaScript regular expression."),
427
+ ignoreCase: z.boolean().optional(),
428
+ limit: z.number().int().min(1).max(500).optional(),
429
+ },
430
+ annotations: { readOnlyHint: true, openWorldHint: false },
431
+ },
432
+ tool<{ text: string; regex?: boolean; ignoreCase?: boolean; limit?: number }>(35_000, "find", async (s, a, signal) => {
433
+ await s.settle({ signal });
434
+ const buffer = s.buffer;
435
+ if (!buffer) throw new SessionError("The app is not mounted.");
436
+ let matches;
437
+ try {
438
+ matches = findText(buffer, a.text, { regex: a.regex, ignoreCase: a.ignoreCase, limit: a.limit });
439
+ } catch (error) {
440
+ throw new SessionError(`Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
441
+ }
442
+ const grid = s.sceneGrid;
443
+ const hitTest = grid ? createHitTester(s.fiberProbe, grid) : () => ({ component: null, path: [] as string[] });
444
+ const enriched = matches.map((m) => {
445
+ const hit = hitTest(m.x, m.y);
446
+ return { ...m, ...(hit.component ? { component: hit.component, path: hit.path } : {}) };
447
+ });
448
+ const text =
449
+ enriched.length === 0
450
+ ? `No match for ${JSON.stringify(a.text)}.`
451
+ : enriched.map((m) => `${formatBounds(m)}${m.component ? ` ${m.component}` : ""} ${JSON.stringify(m.line)}`).join("\n");
452
+ return { content: [{ type: "text", text }], structuredContent: { matches: enriched } };
453
+ }),
454
+ );
455
+
456
+ server.registerTool(
457
+ "wait_for",
458
+ {
459
+ title: "Wait for",
460
+ description:
461
+ "Wait until `text` appears, `gone` disappears, and/or `href` matches the route. With no condition wait for idle. Returns the matching frame; use styles:true to capture transient styled output without racing a second read. Timeouts include the last frame.",
462
+ inputSchema: {
463
+ text: z.union([z.string().min(1).max(1000), z.array(z.string().min(1).max(1000)).min(1).max(8)]).optional().describe("One visible string, or up to eight strings that must all be on the same frame."),
464
+ gone: z.string().min(1).max(1000).optional(),
465
+ href: z.string().startsWith("/").max(4096).optional().describe("Exact app pathname + search + hash (not a route pattern)."),
466
+ styles: z.boolean().optional(),
467
+ trim: z.boolean().optional(),
468
+ idle: z.boolean().optional().describe("Also require idle after the text condition holds."),
469
+ quietMs: z.number().int().min(10).max(10_000).optional(),
470
+ timeoutMs: z.number().int().min(0).max(120_000).optional().describe("Default 5000."),
471
+ },
472
+ annotations: { readOnlyHint: true, openWorldHint: false },
473
+ },
474
+ tool<{ text?: string | string[]; gone?: string; href?: string; styles?: boolean; trim?: boolean; idle?: boolean; quietMs?: number; timeoutMs?: number }>(
475
+ (a) => (a.timeoutMs ?? 5000) + 5000,
476
+ "wait_for",
477
+ async (s, a, signal) => {
478
+ const timeout = a.timeoutMs ?? 5000;
479
+ const start = Date.now();
480
+ const required = Array.isArray(a.text) ? a.text : a.text === undefined ? [] : [a.text];
481
+ const textOk = (): boolean => (a.href === undefined || a.href === s.href) && required.every(text => s.contains(text)) && (a.gone === undefined || !s.contains(a.gone));
482
+ const needIdle = a.idle === true || (a.text === undefined && a.gone === undefined && a.href === undefined);
483
+ for (;;) {
484
+ if (signal.aborted) throw signal.reason;
485
+ // Fail fast when the app exited, crashed or failed to load: nothing will change.
486
+ s.assertRunning();
487
+ if (textOk() && (!needIdle || s.isIdle(a.quietMs))) break;
488
+ if (Date.now() - start >= timeout) {
489
+ const f = s.frame({ peek: true });
490
+ const what = [a.text !== undefined ? `text ${JSON.stringify(a.text)}` : "", a.gone !== undefined ? `${JSON.stringify(a.gone)} to disappear` : "", a.href !== undefined ? `route ${JSON.stringify(a.href)}` : "", needIdle ? "idle" : ""]
491
+ .filter(Boolean)
492
+ .join(" and ");
493
+ throw new TimeoutError(`wait_for timed out after ${timeout}ms waiting for ${what}.\n\n${formatFrame(f)}`);
494
+ }
495
+ await new Promise((r) => setTimeout(r, 10));
496
+ }
497
+ return frameResult(s.frame({ styles: a.styles, trim: a.trim }), { waitedMs: Date.now() - start });
498
+ },
499
+ ),
500
+ );
501
+
502
+ server.registerTool(
503
+ "logs",
504
+ {
505
+ title: "Logs",
506
+ description:
507
+ "Captured console output and React errors (caught by error boundaries, uncaught, and recoverable), newest last. Each entry has a `seq`; pass the last seen `seq` as `since` to read only new entries.",
508
+ inputSchema: {
509
+ since: z.number().int().min(0).optional(),
510
+ level: z.array(z.enum(["log", "info", "warn", "error", "debug"])).optional(),
511
+ limit: z.number().int().min(1).max(1000).optional().describe("Default 100 (newest kept)."),
512
+ clear: z.boolean().optional().describe("Clear the buffer after reading."),
513
+ },
514
+ annotations: { readOnlyHint: false, openWorldHint: false },
515
+ },
516
+ async (a: { since?: number; level?: LogLevel[]; limit?: number; clear?: boolean }): Promise<CallToolResult> => {
517
+ // Readable even when the app failed to load (that is when logs matter most).
518
+ const entries = controller.logs.read({ since: a.since, levels: a.level, limit: a.limit ?? 100 });
519
+ if (a.clear) controller.logs.clear();
520
+ const text =
521
+ entries.length === 0
522
+ ? "(no log entries)"
523
+ : entries.map((e) => `#${e.seq} +${e.t}ms ${e.level}${e.source !== "console" ? ` [${e.source}]` : ""}${e.code ? ` ${e.code}` : ""}: ${e.message}`).join("\n");
524
+ return { content: [{ type: "text", text }], structuredContent: { entries, cursor: controller.logs.cursor } };
525
+ },
526
+ );
527
+
528
+ server.registerTool(
529
+ "fetch",
530
+ {
531
+ title: "Fetch route handler",
532
+ description: "Call one of the app's route handlers (app/**/route.ts) in-process, exactly as `text dev` serves it. Only same-origin paths like \"/api/health?x=1\".",
533
+ inputSchema: {
534
+ path: z.string().min(1).max(4096),
535
+ method: z.enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]).optional(),
536
+ headers: z.record(z.string(), z.string()).optional(),
537
+ body: z.string().max(1_000_000).optional(),
538
+ timeoutMs: z.number().int().min(1).max(120_000).optional().describe("Default 30000."),
539
+ maxBytes: z.number().int().min(1).max(4_000_000).optional().describe("Max body bytes to read (default 65536); the rest is cancelled."),
540
+ streamMs: z.number().int().min(0).max(60_000).optional().describe("text/event-stream responses: read this long, then cancel the stream (default 1000)."),
541
+ },
542
+ annotations: { readOnlyHint: false, openWorldHint: false },
543
+ },
544
+ tool<{ path: string; method?: string; headers?: Record<string, string>; body?: string; timeoutMs?: number; maxBytes?: number; streamMs?: number }>(
545
+ (a) => a.timeoutMs ?? 30_000,
546
+ "fetch",
547
+ async (s, a, signal) => {
548
+ const r = await s.request(a.path, { method: a.method, headers: a.headers, body: a.body, maxBytes: a.maxBytes, streamMs: a.streamMs }, signal);
549
+ const head = `${r.status} ${r.statusText}`.trim() + (r.route ? ` (${r.route})` : " (no matching route handler)");
550
+ const headers = Object.entries(r.headers)
551
+ .map(([k, v]) => `${k}: ${v}`)
552
+ .join("\n");
553
+ const text = `${head}\n${headers}\n\n${r.body}${r.streaming ? "\n… (stream still open; cancelled after streamMs)" : r.truncated ? "\n… (truncated)" : ""}`;
554
+ return { content: [{ type: "text", text }], structuredContent: r as unknown as Record<string, unknown> };
555
+ },
556
+ ),
557
+ );
558
+
559
+ server.registerTool(
560
+ "reload",
561
+ {
562
+ title: "Reload",
563
+ description: "Re-import the app after editing files: unmounts, purges project modules from the module cache, re-scans app/, remounts at the current location and returns the settled frame. Also retries a failed start.",
564
+ inputSchema: { settleMs: settleSchema },
565
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
566
+ },
567
+ (a: { settleMs?: number }): Promise<CallToolResult> =>
568
+ locked(60_000, "reload", async (signal) => {
569
+ const { session, purged } = await controller.reload();
570
+ return frameResult(await settleThen(session, a.settleMs, {}, signal), { purged }, `reloaded (${purged} modules re-imported)`);
571
+ }),
572
+ );
573
+
574
+ server.registerResource(
575
+ "frame",
576
+ "text://frame",
577
+ { title: "Current frame", description: "The current frame as plain text (with a route/focus header).", mimeType: "text/plain" },
578
+ async (uri) => {
579
+ const s = await withTimeout(controller.get(), 35_000, "text://frame");
580
+ return { contents: [{ uri: uri.href, mimeType: "text/plain", text: formatFrame(s.frame({ peek: true })) }] };
581
+ },
582
+ );
583
+
584
+ server.registerResource(
585
+ "routes",
586
+ "text://routes",
587
+ { title: "Routes", description: "Pages and route handlers in app/ (JSON).", mimeType: "application/json" },
588
+ async (uri) => {
589
+ const s = await withTimeout(controller.get(), 35_000, "text://routes");
590
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(s.routes(), null, 2) }] };
591
+ },
592
+ );
593
+
594
+ return { server, controller };
595
+ }