@terreno/mcp 57.19.2 → 57.20.0

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 (197) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/README.md +32 -269
  3. package/dist/bootstrap.d.ts +12 -0
  4. package/dist/bootstrap.d.ts.map +1 -1
  5. package/dist/bootstrap.js +77 -122
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/docs/guidelines/ui/guidelines/core.md +1 -1
  8. package/dist/docs/resources/overview.md +4 -2
  9. package/dist/docs/resources/patterns.md +237 -176
  10. package/dist/docs/resources/ui.md +48 -17
  11. package/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
  12. package/dist/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
  13. package/dist/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
  14. package/dist/docs/upgrades/57.20.0.md +238 -0
  15. package/dist/docs/versioned/next/approval-rubric.md +30 -12
  16. package/dist/docs/versioned/next/explanation/README.md +5 -0
  17. package/dist/docs/versioned/next/explanation/account-linking.md +169 -0
  18. package/dist/docs/versioned/next/explanation/admin-interface.md +1 -1
  19. package/dist/docs/versioned/next/explanation/agent-guidelines.md +173 -0
  20. package/dist/docs/versioned/next/explanation/ai-development-loop.md +196 -0
  21. package/dist/docs/versioned/next/explanation/authentication.md +45 -1
  22. package/dist/docs/versioned/next/explanation/consent-forms.md +105 -0
  23. package/dist/docs/versioned/next/explanation/example-coverage.md +1 -0
  24. package/dist/docs/versioned/next/explanation/local-first-data.md +1 -1
  25. package/dist/docs/versioned/next/explanation/organizations.md +37 -0
  26. package/dist/docs/versioned/next/explanation/planning-plugins.md +4 -0
  27. package/dist/docs/versioned/next/explanation/responsive-design.md +143 -0
  28. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
  29. package/dist/docs/versioned/next/explanation/schema-metadata.md +5 -0
  30. package/dist/docs/versioned/next/how-to/README.md +7 -2
  31. package/dist/docs/versioned/next/how-to/audit-log.md +22 -3
  32. package/dist/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
  33. package/dist/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
  34. package/dist/docs/versioned/next/how-to/circleci.md +15 -5
  35. package/dist/docs/versioned/next/how-to/configure-better-auth.md +673 -53
  36. package/dist/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
  37. package/dist/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
  38. package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
  39. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
  40. package/dist/docs/versioned/next/how-to/password-reset.md +1 -1
  41. package/dist/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
  42. package/dist/docs/versioned/next/how-to/run-tests-locally.md +37 -8
  43. package/dist/docs/versioned/next/how-to/set-up-social-login.md +424 -0
  44. package/dist/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
  45. package/dist/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
  46. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
  47. package/dist/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
  48. package/dist/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
  49. package/dist/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
  50. package/dist/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
  51. package/dist/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
  52. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
  53. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
  54. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
  55. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
  56. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
  57. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
  58. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
  59. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
  60. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
  61. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
  62. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
  63. package/dist/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
  64. package/dist/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
  65. package/dist/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
  66. package/dist/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
  67. package/dist/docs/versioned/next/reference/README.md +1 -0
  68. package/dist/docs/versioned/next/reference/account-security.md +700 -0
  69. package/dist/docs/versioned/next/reference/admin-backend.md +3 -1
  70. package/dist/docs/versioned/next/reference/admin-frontend.md +225 -1
  71. package/dist/docs/versioned/next/reference/api.md +461 -35
  72. package/dist/docs/versioned/next/reference/cli.md +1 -1
  73. package/dist/docs/versioned/next/reference/comms.md +8 -0
  74. package/dist/docs/versioned/next/reference/create-terreno-app.md +3 -1
  75. package/dist/docs/versioned/next/reference/environment-variables.md +9 -3
  76. package/dist/docs/versioned/next/reference/legacy/rtk.md +19 -3
  77. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
  78. package/dist/docs/versioned/next/reference/mcp-server.md +1158 -441
  79. package/dist/docs/versioned/next/reference/syncdb.md +8 -1
  80. package/dist/docs/versioned/next/reference/ui.md +1271 -27
  81. package/dist/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
  82. package/dist/docs/versioned/next/tutorials/README.md +4 -0
  83. package/dist/httpApp.d.ts +14 -0
  84. package/dist/httpApp.d.ts.map +1 -0
  85. package/dist/httpApp.js +99 -0
  86. package/dist/httpApp.js.map +1 -0
  87. package/dist/index.js +8 -90
  88. package/dist/index.js.map +1 -1
  89. package/dist/local/localTools.d.ts.map +1 -1
  90. package/dist/local/localTools.js +44 -14
  91. package/dist/local/localTools.js.map +1 -1
  92. package/dist/local/metro/metroDevSession.d.ts +18 -0
  93. package/dist/local/metro/metroDevSession.d.ts.map +1 -1
  94. package/dist/local/metro/metroDevSession.js +42 -3
  95. package/dist/local/metro/metroDevSession.js.map +1 -1
  96. package/dist/local/tools/databaseQuery.d.ts.map +1 -1
  97. package/dist/local/tools/databaseQuery.js +3 -13
  98. package/dist/local/tools/databaseQuery.js.map +1 -1
  99. package/dist/local/tools/databaseSchema.d.ts.map +1 -1
  100. package/dist/local/tools/databaseSchema.js +3 -13
  101. package/dist/local/tools/databaseSchema.js.map +1 -1
  102. package/dist/local/tools/localMongo.d.ts +10 -0
  103. package/dist/local/tools/localMongo.d.ts.map +1 -0
  104. package/dist/local/tools/localMongo.js +30 -0
  105. package/dist/local/tools/localMongo.js.map +1 -0
  106. package/dist/local/tools/runtime.d.ts +18 -2
  107. package/dist/local/tools/runtime.d.ts.map +1 -1
  108. package/dist/local/tools/runtime.js +75 -20
  109. package/dist/local/tools/runtime.js.map +1 -1
  110. package/dist/local/tools/syncDb.d.ts +11 -0
  111. package/dist/local/tools/syncDb.d.ts.map +1 -1
  112. package/dist/local/tools/syncDb.js +15 -3
  113. package/dist/local/tools/syncDb.js.map +1 -1
  114. package/dist/rateLimit.d.ts +35 -0
  115. package/dist/rateLimit.d.ts.map +1 -0
  116. package/dist/rateLimit.js +112 -0
  117. package/dist/rateLimit.js.map +1 -0
  118. package/dist/tools.d.ts.map +1 -1
  119. package/dist/tools.js +42 -60
  120. package/dist/tools.js.map +1 -1
  121. package/package.json +4 -3
  122. package/src/docs/guidelines/ui/guidelines/core.md +1 -1
  123. package/src/docs/resources/overview.md +4 -2
  124. package/src/docs/resources/patterns.md +237 -176
  125. package/src/docs/resources/ui.md +48 -17
  126. package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +0 -26
  127. package/src/docs/templates/bootstrap/frontend/app/_layout.template.tsx +3 -2
  128. package/src/docs/templates/bootstrap/frontend/app/login.template.tsx +1 -1
  129. package/src/docs/upgrades/57.20.0.md +238 -0
  130. package/src/docs/versioned/next/approval-rubric.md +30 -12
  131. package/src/docs/versioned/next/explanation/README.md +5 -0
  132. package/src/docs/versioned/next/explanation/account-linking.md +169 -0
  133. package/src/docs/versioned/next/explanation/admin-interface.md +1 -1
  134. package/src/docs/versioned/next/explanation/agent-guidelines.md +173 -0
  135. package/src/docs/versioned/next/explanation/ai-development-loop.md +196 -0
  136. package/src/docs/versioned/next/explanation/authentication.md +45 -1
  137. package/src/docs/versioned/next/explanation/consent-forms.md +105 -0
  138. package/src/docs/versioned/next/explanation/example-coverage.md +1 -0
  139. package/src/docs/versioned/next/explanation/local-first-data.md +1 -1
  140. package/src/docs/versioned/next/explanation/organizations.md +37 -0
  141. package/src/docs/versioned/next/explanation/planning-plugins.md +4 -0
  142. package/src/docs/versioned/next/explanation/responsive-design.md +143 -0
  143. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +11 -0
  144. package/src/docs/versioned/next/explanation/schema-metadata.md +5 -0
  145. package/src/docs/versioned/next/how-to/README.md +7 -2
  146. package/src/docs/versioned/next/how-to/audit-log.md +22 -3
  147. package/src/docs/versioned/next/how-to/automate-terreno-upgrades.md +169 -0
  148. package/src/docs/versioned/next/how-to/build-list-detail-screens.md +160 -0
  149. package/src/docs/versioned/next/how-to/circleci.md +15 -5
  150. package/src/docs/versioned/next/how-to/configure-better-auth.md +673 -53
  151. package/src/docs/versioned/next/how-to/create-a-terreno-app.md +8 -5
  152. package/src/docs/versioned/next/how-to/debug-with-mcp.md +393 -23
  153. package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +54 -3
  154. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +9 -0
  155. package/src/docs/versioned/next/how-to/password-reset.md +1 -1
  156. package/src/docs/versioned/next/how-to/recover-locked-out-user.md +112 -0
  157. package/src/docs/versioned/next/how-to/run-tests-locally.md +37 -8
  158. package/src/docs/versioned/next/how-to/set-up-social-login.md +424 -0
  159. package/src/docs/versioned/next/how-to/set-up-terreno-mcp.md +241 -36
  160. package/src/docs/versioned/next/how-to/sign-consent-on-behalf.md +139 -0
  161. package/src/docs/versioned/next/how-to/upgrade-terreno.md +4 -0
  162. package/src/docs/versioned/next/implementationPlans/ai-dev-loop-boost.md +47 -32
  163. package/src/docs/versioned/next/implementationPlans/auth-experience.md +952 -0
  164. package/src/docs/versioned/next/implementationPlans/consent-forms.md +8 -0
  165. package/src/docs/versioned/next/implementationPlans/list-detail-layout.md +297 -0
  166. package/src/docs/versioned/next/implementationPlans/login-redesign/HANDOFF.md +211 -0
  167. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/AnotherWay.html +121 -0
  168. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/BackupCode.html +72 -0
  169. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Cancelled.html +87 -0
  170. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ContinueEmail.html +64 -0
  171. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/FirstVisit.html +81 -0
  172. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/Main.html +88 -0
  173. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/ManageAccounts.html +77 -0
  174. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/NotLinked.html +92 -0
  175. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/SigningIn.html +60 -0
  176. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactor.html +76 -0
  177. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/TwoFactorError.html +81 -0
  178. package/src/docs/versioned/next/implementationPlans/login-redesign/boards/WrongPassword.html +69 -0
  179. package/src/docs/versioned/next/implementationPlans/mcp-boost-parity.md +1 -1
  180. package/src/docs/versioned/next/plans/2026-10-23-provisional-consent.md +297 -0
  181. package/src/docs/versioned/next/plans/2026-10-29-fix-mcp-oauth-native-and-machine.md +68 -0
  182. package/src/docs/versioned/next/reference/README.md +1 -0
  183. package/src/docs/versioned/next/reference/account-security.md +700 -0
  184. package/src/docs/versioned/next/reference/admin-backend.md +3 -1
  185. package/src/docs/versioned/next/reference/admin-frontend.md +225 -1
  186. package/src/docs/versioned/next/reference/api.md +461 -35
  187. package/src/docs/versioned/next/reference/cli.md +1 -1
  188. package/src/docs/versioned/next/reference/comms.md +8 -0
  189. package/src/docs/versioned/next/reference/create-terreno-app.md +3 -1
  190. package/src/docs/versioned/next/reference/environment-variables.md +9 -3
  191. package/src/docs/versioned/next/reference/legacy/rtk.md +19 -3
  192. package/src/docs/versioned/next/reference/lifecycle-plugin.md +6 -0
  193. package/src/docs/versioned/next/reference/mcp-server.md +1158 -441
  194. package/src/docs/versioned/next/reference/syncdb.md +8 -1
  195. package/src/docs/versioned/next/reference/ui.md +1271 -27
  196. package/src/docs/versioned/next/tasks/ai-dev-loop-boost.md +28 -16
  197. package/src/docs/versioned/next/tutorials/README.md +4 -0
@@ -37,7 +37,7 @@ Optional flags:
37
37
  | `appName` (positional) | yes | — kebab-case directory and package name (`^[a-z][a-z0-9-]*$`) |
38
38
  | `--display-name` | unless `--yes` | derived from `appName` when `--yes` |
39
39
  | `--description` | no | empty |
40
- | `--mcp-server-url` | no | `https://mcp.terreno.app` |
40
+ | `--mcp-server-url` | no | `https://mcp.terreno.app` (base URL; the CLI appends `/mcp`) |
41
41
  | `--yes` | no | off |
42
42
 
43
43
  The CLI creates `<cwd>/<appName>/`. Package source: [`create-terreno-app/`](https://github.com/TerrenoLabs/terreno/tree/master/create-terreno-app) in the Terreno monorepo.
@@ -61,11 +61,11 @@ cd .. && bunx create-terreno-app my-app --yes
61
61
  | `frontend/` | Expo universal app (`@terreno/ui`, `@terreno/syncdb`, `@terreno/rtk`) |
62
62
  | `Dockerfile` | Consumer backend image (`backend/` only — not the Terreno monorepo) |
63
63
  | `.dockerignore` | Excludes local env secrets, dependencies, and build output from the image context |
64
- | `backend/.env`, `frontend/.env` | Gitignored local-dev defaults (written on scaffold) |
64
+ | `backend/.env`, `frontend/.env` | Gitignored local-dev defaults (written on scaffold). `backend/.env` sets `NODE_ENV=development`, which turns on the dev-only `POST /__terreno/browser-logs` route |
65
65
  | `backend/.env.example`, `frontend/.env.example` | Committed templates for production and export |
66
66
  | `.github/workflows/` | Backend and frontend CI |
67
- | `.cursor/mcp.json`, `.cursorrules`, `CLAUDE.md` | Agent tooling |
68
- | `README.md` | App-specific next steps and deploy links |
67
+ | `.cursor/mcp.json`, `.mcp.json`, `.cursorrules`, `CLAUDE.md` | Agent tooling ([Set up Terreno MCP](set-up-terreno-mcp.md)) |
68
+ | `README.md` | App-specific next steps, deploy links, and a link to [Set up Terreno MCP](set-up-terreno-mcp.md) |
69
69
 
70
70
  Generated `@terreno/*` dependencies pin to `^<create-terreno-app version>` at scaffold time.
71
71
 
@@ -130,6 +130,8 @@ Open `http://localhost:8082` and sign in as `test@example.com` / `testpassword12
130
130
 
131
131
  `frontend/.env` defaults `EXPO_PUBLIC_API_URL` to `http://localhost:4000`. For production web builds, set it **before** export — see [Build for web](build-for-web.md).
132
132
 
133
+ In development, `frontend/app/_layout.tsx` calls `installTerrenoDevConsoleLogger({baseUrl})` from `@terreno/syncdb` at startup. It sends console errors, warnings, and uncaught errors to the backend's `POST /__terreno/browser-logs`, which writes `backend/.terreno/logs/browser.log`. `terreno-mcp-local` reads that file for `read_logs` and `last_error` with the `browser` source. The call does nothing in production builds. See [Set up Terreno MCP](set-up-terreno-mcp.md).
134
+
133
135
  ## Deploy
134
136
 
135
137
  Read the [deployment baseline](../explanation/deployment-baseline.md) first.
@@ -142,10 +144,11 @@ docker run --rm -p 8080:8080 \
142
144
  -e MONGO_URI='mongodb+srv://...' \
143
145
  -e BETTER_AUTH_SECRET='...' \
144
146
  -e BETTER_AUTH_URL='https://api.example.com' \
147
+ -e FRONTEND_URL='https://app.example.com' \
145
148
  my-app-backend
146
149
  ```
147
150
 
148
- Cloud Run concepts and commands: [Deploy backend to Cloud Run](deploy-backend-to-cloud-run.md). That guide targets Terreno's monorepo example; for a generated app, keep the root `Dockerfile`, port `8080`, and Better Auth variables shown here. The generated backend throws if `BETTER_AUTH_SECRET` is unset. `GET /users` list and read require an admin.
151
+ Cloud Run concepts and commands: [Deploy backend to Cloud Run](deploy-backend-to-cloud-run.md). That guide targets Terreno's monorepo example; for a generated app, keep the root `Dockerfile`, port `8080`, and Better Auth variables shown here. The generated backend throws if `BETTER_AUTH_SECRET` is unset. Set `FRONTEND_URL` to the web app's URL: until it is set, a production backend leaves passkeys off (see [Auth features in generated apps](configure-better-auth.md#auth-features-in-generated-apps)). `GET /users` list and read require an admin.
149
152
 
150
153
  **Frontend:** [Build for web](build-for-web.md) — set `EXPO_PUBLIC_API_URL` at export time.
151
154
 
@@ -1,18 +1,378 @@
1
1
  # Debug a Terreno app with MCP
2
2
 
3
- Start the backend and frontend, reproduce the failure once, then inspect the merged runtime evidence
4
- instead of copying logs into the agent chat.
3
+ Use the local MCP server (`terreno-mcp-local`) to go from a symptom to a verified fix without
4
+ pasting logs into the chat. This guide follows one real failure from start to finish, then lists
5
+ what each tool does. Every tool output below was copied unchanged from a real run against
6
+ `example-backend` and `example-frontend` on web.
7
+
8
+ ## Which tool for which symptom
9
+
10
+ | Symptom | Start with | Then |
11
+ |---------|------------|------|
12
+ | API returns 500 | `last_error` with `sources: ["backend"]`. The stack is inside `message`. | `read_logs` with `sources: ["backend"]` and `since` to see the requests around it. |
13
+ | API returns 403 (or another 4xx) | `read_logs` with `sources: ["backend"]` and `level: "warn"`. Every `APIError` under 500 is logged at `warn` as `ForbiddenError(403): <title> — <detail>`, so `last_error` does not show it. | `database_query` to compare the document's `ownerId` with the user in the log line. |
14
+ | App crashes on a screen | Web: `last_error` with `sources: ["browser"]`. An uncaught error carries a separate `stack` field. A render error caught by an error boundary has no `stack`; its `message` names the component instead (see [A screen crashes on web](#a-screen-crashes-on-web)). Native: `last_error` with `sources: ["app"]`. | Native: `navigate` to the screen and call `last_error` again. Web: the `browser` tool. |
15
+ | Data looks wrong | `database_query` to read what the server stored. | `get_client_state` (RTK cache) or `get_syncdb_state` (SyncDB) to compare the client's copy. |
16
+ | Screen renders empty | `get_client_state` or `get_syncdb_state` on native. On web, `browser` with `snapshot`. | `read_logs` with `sources: ["backend"]` to check that the list request ran and returned 200. |
17
+ | Bundler fails | The terminal that runs `expo start`. The `metro` source is always empty under Expo CLI 57: the `/events` socket connects but Expo never sends on it. | On native, `read_logs` with `sources: ["app"]` once the app loads again. |
18
+
19
+ `level` matches one level exactly. `level: "error"` does not include `warn`.
20
+
21
+ ## Walkthrough: a malformed request body returns 500
22
+
23
+ The run used the example backend on port 4200 with `NODE_ENV=development`, the web app on port
24
+ 8282, and the seeded user `test@example.com`. The MCP server was started with
25
+ `TERRENO_PROJECT_ROOT` set to the repository and `TERRENO_METRO_URL=http://localhost:8282`.
26
+
27
+ ### 1. See the symptom
28
+
29
+ A client sent a JSON body with a typo (`fals`). The server answered 500. A malformed body is the
30
+ client's mistake, so it should be a 400 that says what is wrong.
31
+
32
+ ```text
33
+ $ curl -s -i -b cookies.txt -X POST localhost:4200/todos -H 'Content-Type: application/json' --data '{"title": "Buy milk", "completed": fals}'
34
+ HTTP/1.1 500 Internal Server Error
35
+ X-Powered-By: Express
36
+ X-Request-ID: 5ccfb98c-f592-42ce-9503-415c0c75f32f
37
+ Vary: Origin
38
+ Access-Control-Allow-Credentials: true
39
+ Content-Type: application/json; charset=utf-8
40
+ Content-Length: 46
41
+ Date: Mon, 12 Oct 2026 02:45:16 GMT
42
+ Connection: keep-alive
43
+ Keep-Alive: timeout=5
44
+
45
+ {"status":500,"title":"Internal server error"}
46
+ ```
47
+
48
+ ### 2. Locate it with `last_error`
49
+
50
+ ```json
51
+ {"name":"last_error","arguments":{"sources":["backend","browser"]}}
52
+ ```
53
+
54
+ ```json
55
+ {"level":"error","message":"Fallthrough error: SyntaxError: JSON Parse error: Unexpected identifier \"fals\"\nSyntaxError: JSON Parse error: Unexpected identifier \"fals\"\n at <parse> (:0)\n at parse (unknown)\n at parse (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/body-parser/lib/types/json.js:91:21)\n at <anonymous> (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/body-parser/lib/read.js:162:18)\n at runInAsyncScope (node:async_hooks:207:23)\n at invokeCallback (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:238:16)\n at done (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:227:7)\n at onEnd (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:287:7)\n at emit (node:events:97:22)\n at endReadableNT (internal:streams/readable:901:50)\n at processTicksAndRejections (native:7:39)","requestId":"5ccfb98c-f592-42ce-9503-415c0c75f32f","terrenoRequestLog":{"requestId":"5ccfb98c-f592-42ce-9503-415c0c75f32f","userId":null},"timestamp":"2026-10-12T02:45:16.902Z"}
56
+ ```
57
+
58
+ What this tells you:
59
+
60
+ - `requestId` matches the `X-Request-ID` header from step 1, so this is the same request.
61
+ - `Fallthrough error` comes from `apiFallthroughErrorMiddleware`. That handler only sees errors
62
+ that `apiErrorMiddleware` could not turn into an `APIError`, and it always answers 500.
63
+ - The top frame is `body-parser/lib/types/json.js`. The body failed to parse before any route ran.
64
+ - `userId` is `null` even though the request sent a session cookie. Authentication runs after body
65
+ parsing, so the request never got that far.
66
+
67
+ ### 3. Read the surrounding context with `read_logs`
68
+
69
+ Filter to the backend and start from a time just before the failure. A valid request for the same
70
+ route was sent afterwards to compare.
71
+
72
+ ```json
73
+ {"name":"read_logs","arguments":{"sources":["backend"],"since":"2026-10-12T02:45:02.876Z","entries":20}}
74
+ ```
75
+
76
+ ```json
77
+ {
78
+ "connection": "CDP: not connected; Metro events: not connected",
79
+ "entries": [
80
+ {
81
+ "level": "error",
82
+ "message": "Fallthrough error: SyntaxError: JSON Parse error: Unexpected identifier \"fals\"\nSyntaxError: JSON Parse error: Unexpected identifier \"fals\"\n at <parse> (:0)\n at parse (unknown)\n at parse (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/body-parser/lib/types/json.js:91:21)\n at <anonymous> (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/body-parser/lib/read.js:162:18)\n at runInAsyncScope (node:async_hooks:207:23)\n at invokeCallback (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:238:16)\n at done (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:227:7)\n at onEnd (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:287:7)\n at emit (node:events:97:22)\n at endReadableNT (internal:streams/readable:901:50)\n at processTicksAndRejections (native:7:39)",
83
+ "raw": "{\"level\":\"error\",\"message\":\"Fallthrough error: SyntaxError: JSON Parse error: Unexpected identifier \\\"fals\\\"\\nSyntaxError: JSON Parse error: Unexpected identifier \\\"fals\\\"\\n at <parse> (:0)\\n at parse (unknown)\\n at parse (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/body-parser/lib/types/json.js:91:21)\\n at <anonymous> (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/body-parser/lib/read.js:162:18)\\n at runInAsyncScope (node:async_hooks:207:23)\\n at invokeCallback (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:238:16)\\n at done (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:227:7)\\n at onEnd (/workspace/.claude/worktrees/agent-a4ec9221d12c64054/node_modules/raw-body/index.js:287:7)\\n at emit (node:events:97:22)\\n at endReadableNT (internal:streams/readable:901:50)\\n at processTicksAndRejections (native:7:39)\",\"requestId\":\"5ccfb98c-f592-42ce-9503-415c0c75f32f\",\"terrenoRequestLog\":{\"requestId\":\"5ccfb98c-f592-42ce-9503-415c0c75f32f\",\"userId\":null},\"timestamp\":\"2026-10-12T02:45:16.902Z\"}",
84
+ "source": "backend",
85
+ "timestamp": "2026-10-12T02:45:16.902Z"
86
+ },
87
+ {
88
+ "level": "debug",
89
+ "message": "POST <- /todos <User:6acc4989b98fcbeba02df577> Body: {\"title\":\"Buy milk\",\"completed\":false}",
90
+ "raw": "{\"level\":\"debug\",\"message\":\"POST <- /todos <User:6acc4989b98fcbeba02df577> Body: {\\\"title\\\":\\\"Buy milk\\\",\\\"completed\\\":false}\",\"requestId\":\"91b0a231-17d7-42ef-8794-570410c99fc7\",\"sessionId\":\"6acc49aed7ff6fef9a4a80c9\",\"terrenoRequestLog\":{\"requestId\":\"91b0a231-17d7-42ef-8794-570410c99fc7\",\"userId\":\"6acc4989b98fcbeba02df577\"},\"timestamp\":\"2026-10-12T02:45:26.017Z\",\"userId\":\"6acc4989b98fcbeba02df577\"}",
91
+ "source": "backend",
92
+ "timestamp": "2026-10-12T02:45:26.017Z"
93
+ },
94
+ {
95
+ "level": "info",
96
+ "message": "[comms:mail] recipients=1 subjectLength=10",
97
+ "raw": "{\"level\":\"info\",\"message\":\"[comms:mail] recipients=1 subjectLength=10\",\"requestId\":\"91b0a231-17d7-42ef-8794-570410c99fc7\",\"sessionId\":\"6acc49aed7ff6fef9a4a80c9\",\"terrenoRequestLog\":{\"requestId\":\"91b0a231-17d7-42ef-8794-570410c99fc7\",\"userId\":\"6acc4989b98fcbeba02df577\"},\"timestamp\":\"2026-10-12T02:45:26.101Z\",\"userId\":\"6acc4989b98fcbeba02df577\"}",
98
+ "source": "backend",
99
+ "timestamp": "2026-10-12T02:45:26.101Z"
100
+ },
101
+ {
102
+ "level": "debug",
103
+ "message": "POST -> /todos 201 108.791011ms",
104
+ "raw": "{\"level\":\"debug\",\"message\":\"POST -> /todos 201 108.791011ms\",\"requestId\":\"91b0a231-17d7-42ef-8794-570410c99fc7\",\"sessionId\":\"6acc49aed7ff6fef9a4a80c9\",\"terrenoRequestLog\":{\"requestId\":\"91b0a231-17d7-42ef-8794-570410c99fc7\",\"userId\":\"6acc4989b98fcbeba02df577\"},\"timestamp\":\"2026-10-12T02:45:26.126Z\",\"userId\":\"6acc4989b98fcbeba02df577\"}",
105
+ "source": "backend",
106
+ "timestamp": "2026-10-12T02:45:26.126Z"
107
+ }
108
+ ],
109
+ "projectRoot": "/workspace/.claude/worktrees/agent-a4ec9221d12c64054"
110
+ }
111
+ ```
112
+
113
+ The valid request logs `POST <- /todos` with the user and `POST -> /todos 201`. The malformed one
114
+ has neither line, only the error. That confirms it never reached request logging, auth, or the
115
+ `/todos` router. The fix belongs in the app-level error handling, not in the todo route.
116
+
117
+ `raw` is the log line exactly as the backend wrote it. `connection` reports the Metro and Hermes
118
+ sources; it does not affect `backend` entries.
119
+
120
+ ### 4. Check the data with `database_query`
121
+
122
+ Did the failed request write anything?
123
+
124
+ ```json
125
+ {"name":"database_query","arguments":{"collection":"todos","operation":"find","filter":{"title":"Buy milk"},"limit":5}}
126
+ ```
127
+
128
+ ```json
129
+ {
130
+ "collection": "todos",
131
+ "count": 1,
132
+ "rows": [
133
+ {
134
+ "_id": "6acc49c6d7ff6fef9a4a80ca",
135
+ "completed": false,
136
+ "ownerId": "6acc4989b98fcbeba02df577",
137
+ "tags": [],
138
+ "title": "Buy milk",
139
+ "updated": "2026-10-12T02:45:26.060Z",
140
+ "created": "2026-10-12T02:45:26.060Z",
141
+ "deleted": false,
142
+ "_syncPrevStream": null,
143
+ "_syncSeq": 3,
144
+ "__v": 0
145
+ }
146
+ ]
147
+ }
148
+ ```
149
+
150
+ One row, created at `02:45:26` by the valid request. The malformed request at `02:45:16` wrote
151
+ nothing, so there is no bad data to clean up.
152
+
153
+ ### 5. Check the client with `get_client_state`
154
+
155
+ ```json
156
+ {"name":"get_client_state","arguments":{"slice":"rtk"}}
157
+ ```
158
+
159
+ On web this returns `layer: "none"`:
160
+
161
+ ```json
162
+ {
163
+ "connection": "CDP: Error: No debuggable Hermes/React Native target with webSocketDebuggerUrl (is the app running?); Metro events: not connected",
164
+ "hint": "No client data layer found. For RTK Query, call `registerTerrenoDevStore(store)` from @terreno/rtk in __DEV__. For SyncDB, create the client with `createSyncDb({debug: true})`. Keep the app running; when the MCP process cannot see the app heap, start Metro so CDP can reach Hermes (see `connection`).",
165
+ "layer": "none",
166
+ "reasons": {
167
+ "rtk": "Error: No debuggable Hermes/React Native target with webSocketDebuggerUrl (is the app running?)",
168
+ "syncdb": "Error: Error: No debuggable Hermes/React Native target with webSocketDebuggerUrl (is the app running?)\nCDP: Error: No debuggable Hermes/React Native target with webSocketDebuggerUrl (is the app running?); Metro events: not connected"
169
+ }
170
+ }
171
+ ```
172
+
173
+ `get_client_state` reads the client through Hermes over CDP. A web page runs in the browser, not
174
+ Hermes, so there is no target even though the store is registered. To check client state on web,
175
+ use the `browser` tool (step 8). On iOS or Android, run a development build attached to Metro and
176
+ the same call returns the RTK or SyncDB state.
5
177
 
6
- ## 1. Read the latest error
178
+ This failure is server-side, so the client check only confirms there is nothing to fix there.
7
179
 
8
- Call the local MCP tool:
180
+ ### 6. Fix it
181
+
182
+ `apiErrorMiddleware` already converts Mongoose validation and cast errors to `APIError`s. It now
183
+ also converts body-parser client errors through a new `bodyParserErrorToAPIError` helper in
184
+ `api/src/errors.ts`:
185
+
186
+ ```typescript
187
+ const apiError = isAPIError(err)
188
+ ? err
189
+ : (mongooseErrorToAPIError(err) ?? bodyParserErrorToAPIError(err));
190
+ ```
191
+
192
+ The helper keeps the parser's own 4xx status and message. A malformed body becomes 400
193
+ `Malformed request body`, an oversized body 413 `Request body too large`, and an unsupported
194
+ charset 415 `Unsupported request body encoding`. Errors body-parser marks as server faults still
195
+ fall through to 500. Regression tests in `api/src/errors.test.ts` build an app with the same
196
+ middleware order as `TerrenoApp` and fail without the change:
197
+
198
+ ```bash
199
+ cd api && bun test src/errors.test.ts
200
+ ```
201
+
202
+ Rebuild `@terreno/api` (`bun run compile`) and restart the backend. Check that the old process
203
+ has exited first. In this run, the first restart printed
204
+ `Server start failed: Error: Failed to start server. Is port 4200 in use?` and the old server kept
205
+ answering 500.
206
+
207
+ ### 7. Re-run the symptom
208
+
209
+ Same request against the restarted backend:
210
+
211
+ ```text
212
+ $ curl -s -i -b cookies.txt -X POST localhost:4200/todos -H 'Content-Type: application/json' --data '{"title": "Buy milk", "completed": fals}'
213
+ HTTP/1.1 400 Bad Request
214
+ X-Powered-By: Express
215
+ X-Request-ID: b81e4036-1243-4b0d-a3b4-9e2ec9807664
216
+ Vary: Origin
217
+ Access-Control-Allow-Credentials: true
218
+ Content-Type: application/json; charset=utf-8
219
+ Content-Length: 143
220
+ Date: Mon, 12 Oct 2026 02:47:11 GMT
221
+ Connection: keep-alive
222
+ Keep-Alive: timeout=5
223
+
224
+ {"status":400,"title":"Malformed request body","detail":"JSON Parse error: Unexpected identifier \"fals\"","disableExternalErrorTracking":true}
225
+ ```
226
+
227
+ `last_error` has no `since` argument, so it keeps returning the newest error even after a fix. To
228
+ prove nothing new failed, ask `read_logs` for error-level backend entries since the restart:
229
+
230
+ ```json
231
+ {"name":"read_logs","arguments":{"sources":["backend"],"level":"error","since":"2026-10-12T02:47:10.183Z"}}
232
+ ```
233
+
234
+ ```json
235
+ {
236
+ "connection": "CDP: Error: No debuggable Hermes/React Native target with webSocketDebuggerUrl (is the app running?); Metro events: connected ws://localhost:8282/events",
237
+ "entries": [],
238
+ "projectRoot": "/workspace/.claude/worktrees/agent-a4ec9221d12c64054"
239
+ }
240
+ ```
241
+
242
+ The request is now logged at `warn` as
243
+ `BadRequestError(400): Malformed request body — JSON Parse error: Unexpected identifier "fals"`,
244
+ followed by the parser's stack. Query with `level: "warn"` to see it.
245
+
246
+ ### 8. Confirm in the running app
247
+
248
+ `navigate` drives Expo Router through Hermes, so it has no target on web:
249
+
250
+ ```json
251
+ {"name":"navigate","arguments":{"path":"/"}}
252
+ ```
253
+
254
+ ```json
255
+ {
256
+ "error": "Error: No debuggable Hermes/React Native target with webSocketDebuggerUrl (is the app running?)",
257
+ "status": "CDP: Error: No debuggable Hermes/React Native target with webSocketDebuggerUrl (is the app running?); Metro events: not connected"
258
+ }
259
+ ```
260
+
261
+ On web, use the local `browser` tool instead (it needs `TERRENO_MCP_EVAL=1` for `evaluate`). Open
262
+ the app and log in:
263
+
264
+ ```json
265
+ {"name":"browser","arguments":{"action":"open","url":"http://localhost:8282/login","width":1280,"height":800}}
266
+ {"name":"browser","arguments":{"action":"type","selector":"#login-screen-email-input","text":"test@example.com"}}
267
+ {"name":"browser","arguments":{"action":"type","selector":"#login-screen-password-input","text":"testpassword123"}}
268
+ {"name":"browser","arguments":{"action":"click","selector":"[data-testid=\"login-screen-submit-button\"]"}}
269
+ ```
270
+
271
+ Each step returns the page state, for example the click:
272
+
273
+ ```json
274
+ {
275
+ "action": "click",
276
+ "ok": true,
277
+ "selector": "[data-testid=\"login-screen-submit-button\"]",
278
+ "title": "",
279
+ "url": "http://localhost:8282/login"
280
+ }
281
+ ```
282
+
283
+ On a user's first login, the example app shows one-time Terms of Service and Privacy Policy
284
+ consent forms before the Todos screen. The Terms form needs a drawn signature, which `browser`
285
+ cannot draw. For a scripted run, sign in with `POST /api/auth/sign-in/email`, list the forms with
286
+ `GET /consents/pending`, and accept each one with `POST /consents/respond` and the body
287
+ `{"consentFormId": "<id>", "agreed": true, "locale": "en", "signature": "<data URL>"}`. Then
288
+ `reload` the page.
289
+
290
+ Send the same malformed body from the page, the way a web client would:
291
+
292
+ ```json
293
+ {"name":"browser","arguments":{"action":"evaluate","code":"fetch(\"http://localhost:4200/todos\", {method: \"POST\", credentials: \"include\", headers: {\"Content-Type\": \"application/json\"}, body: \"{\\\"title\\\": \\\"Buy milk\\\", \\\"completed\\\": fals}\"}).then(async (r) => ({status: r.status, body: await r.json()}))"}}
294
+ ```
295
+
296
+ ```json
297
+ {
298
+ "action": "evaluate",
299
+ "ok": true,
300
+ "result": {
301
+ "status": 400,
302
+ "body": {
303
+ "status": 400,
304
+ "title": "Malformed request body",
305
+ "detail": "JSON Parse error: Unexpected identifier \"fals\"",
306
+ "disableExternalErrorTracking": true
307
+ }
308
+ },
309
+ "title": "",
310
+ "url": "http://localhost:8282/"
311
+ }
312
+ ```
313
+
314
+ Then take a screenshot of the todo list:
315
+
316
+ ```json
317
+ {"name":"browser","arguments":{"action":"screenshot","output":".terreno/pipeline/screens/todos-after-fix.png"}}
318
+ ```
319
+
320
+ ```json
321
+ {
322
+ "action": "screenshot",
323
+ "ok": true,
324
+ "output": "/workspace/.claude/worktrees/agent-a4ec9221d12c64054/.terreno/pipeline/screens/todos-after-fix.png",
325
+ "title": "",
326
+ "url": "http://localhost:8282/"
327
+ }
328
+ ```
329
+
330
+ The screenshot shows My Todos with `3 todos · 3 remaining · 0 completed`: the seeded todos plus the
331
+ single `Buy milk`, with no extra row from the rejected requests. A final `read_logs` for
332
+ backend errors since the restart still returned `"entries": []`. The fix is verified.
333
+
334
+ ## A screen crashes on web
335
+
336
+ A second run used the same setup on ports 4400 and 8482. A new priority badge on each todo row
337
+ read `data.priority.toUpperCase()`, and todos created before the field existed have no
338
+ `priority`. The Todos screen showed `Something went wrong` with
339
+ `Cannot read properties of undefined (reading 'toUpperCase')`.
340
+
341
+ ```json
342
+ {"name":"last_error","arguments":{"sources":["backend","browser"]}}
343
+ ```
344
+
345
+ ```json
346
+ {"level":"error","message":"%o\n\n%s\n\n%s\n TypeError: Cannot read properties of undefined (reading 'toUpperCase') The above error occurred in the <SyncTodoItem> component. React will try to recreate this component tree from scratch using the error boundary you provided, Try.","source":"browser","timestamp":"2026-10-12T03:30:28.321+00:00"}
347
+ ```
348
+
349
+ React caught the error in an error boundary and reported it with `console.error`, so:
350
+
351
+ - There is no `stack` field. Only uncaught errors carry one.
352
+ - The message starts with React's unformatted `%o` and `%s` placeholders. Read past them.
353
+ - The component is named: `<SyncTodoItem>`. Search the code for it and for the property in the
354
+ error, here `toUpperCase`.
355
+
356
+ `database_query` with `{"priority": {"$exists": false}}` on `todos` returned three rows, which
357
+ confirmed that the data, not the server, triggers the crash. `read_logs` with
358
+ `sources: ["backend"]` and `level: "error"` returned no entries. Restoring the guard
359
+ (`data.priority ? <Badge …/> : null`) fixed it. `navigate` returned the same
360
+ `No debuggable Hermes/React Native target` error as in step 8, so the check used `browser`:
361
+ `open`, `scroll` to a todo row, then `screenshot`. The row with a priority showed its badge, and the
362
+ rows without one rendered.
363
+
364
+ A later `last_error` may return an unrelated older error, because it has no `since`. Use
365
+ `read_logs` with `level: "error"` and `since` set to the time of the fix instead.
366
+
367
+ ## Tool reference
368
+
369
+ ### Read errors and logs
9
370
 
10
371
  ```json
11
372
  {"name":"last_error","arguments":{"sources":["backend","browser","metro","app"]}}
12
373
  ```
13
374
 
14
- It returns the newest error-level JSONL/CDP entry. Use `read_logs` when the failure needs surrounding
15
- events:
375
+ `last_error` returns the newest error-level entry from the selected sources.
16
376
 
17
377
  ```json
18
378
  {
@@ -20,14 +380,15 @@ events:
20
380
  "arguments": {
21
381
  "entries": 100,
22
382
  "level": "error",
383
+ "since": "2026-10-12T02:45:02.876Z",
23
384
  "sources": ["backend", "browser", "metro", "app"]
24
385
  }
25
386
  }
26
387
  ```
27
388
 
28
- `backend` and `browser` read durable JSONL; `metro` and `app` read in-memory rings populated from
29
- Metro `/events` and CDP. Connection status in the response explains when either live source is
30
- unavailable.
389
+ `backend` and `browser` read durable JSONL files under `.terreno/logs/`. `metro` and `app` read
390
+ in-memory rings filled from Metro `/events` and Hermes CDP. `connection` explains when a live
391
+ source is unavailable.
31
392
 
32
393
  CLI equivalents:
33
394
 
@@ -36,26 +397,31 @@ terreno logs last-error --sources backend,browser,metro,app
36
397
  terreno logs --entries 100 --level error --sources backend,browser,metro,app
37
398
  ```
38
399
 
39
- ## 2. Inspect client state
400
+ ### Inspect client state
40
401
 
41
402
  Check authentication and non-synced RTK Query cache state:
42
403
 
43
404
  ```json
44
- {"name":"get_rtk_state","arguments":{"slice":"rtk","query":"todos"}}
405
+ {"name":"get_client_state","arguments":{"slice":"rtk","query":"todos"}}
45
406
  ```
46
407
 
47
408
  The tool reads `globalThis.__TERRENO_STORE__` registered by the app, using CDP when the MCP process
48
- cannot access the app heap directly. `slice: "auth"` prefers the Better Auth slice and falls back to
49
- legacy JWT auth; pass `betterAuth` to request that exact slice. The CLI equivalent is:
409
+ cannot access the app heap directly. It also detects a SyncDB client created with `debug: true`, and
410
+ its `layer` field reports what it found: `rtk`, `syncdb`, `syncdb+rtk` (both payloads returned), or
411
+ `none` (with a setup hint). On web it returns `none`, as shown in step 5. `get_rtk_state` is a
412
+ deprecated alias for one release. `slice: "auth"` prefers the Better Auth slice and falls back to
413
+ legacy JWT auth; pass `betterAuth` to request that exact slice.
50
414
 
51
415
  Sensitive fields whose names include `password`, `token`, `secret`, `authorization`, or `cookie` are
52
416
  returned as `[REDACTED]`; use this summary to inspect cache state, never to retrieve credentials.
53
417
 
418
+ The CLI equivalent is:
419
+
54
420
  ```bash
55
421
  terreno state --slice rtk --query todos
56
422
  ```
57
423
 
58
- ## 3. Inspect and change SyncDB state
424
+ ### Inspect and change SyncDB state
59
425
 
60
426
  Create the app client with `debug: true`, then inspect the same state that powers
61
427
  the SyncDB debugger:
@@ -85,7 +451,7 @@ use `setLocalEntity` from `syncdb_snapshot` `get`.
85
451
  State changes require an explicit local opt-in:
86
452
 
87
453
  ```bash
88
- TERRENO_MCP_EVAL=1 bunx terreno-mcp-local
454
+ TERRENO_MCP_EVAL=1 bunx -p @terreno/mcp terreno-mcp-local
89
455
  ```
90
456
 
91
457
  Then use `syncdb_action`. Normal writes should use `mutate`; `flush` drains the
@@ -104,9 +470,9 @@ semantics. `mergeSnapshot` CRDT-merges the captured TinyBase content into the
104
470
  live store (later HLCs win) instead of replacing it. Capture a baseline first
105
471
  and use these only for fault injection or repair.
106
472
 
107
- ## 4. Prove the web fix
473
+ ### Drive the web app
108
474
 
109
- Use Bun 1.4's built-in WebView through the local `browser` tool:
475
+ The local `browser` tool uses Bun 1.4's built-in WebView:
110
476
 
111
477
  ```json
112
478
  {"name":"browser","arguments":{"action":"open","url":"http://localhost:8082"}}
@@ -118,8 +484,10 @@ Use Bun 1.4's built-in WebView through the local `browser` tool:
118
484
  ```
119
485
 
120
486
  The session persists between MCP calls. `snapshot` returns visible text and up to 200 interactive
121
- elements so the agent can choose selectors and verify state without image guessing. `screenshot`
122
- saves the viewport for the PR or walkthrough.
487
+ elements so the agent can choose selectors and verify state without image guessing. `wait` sleeps
488
+ for `timeout` milliseconds; it does not wait for a selector. `screenshot` saves the viewport, and
489
+ its `output` must be under the project root or `/opt/cursor/artifacts`. `evaluate` requires
490
+ `TERRENO_MCP_EVAL=1`.
123
491
  WebView console output is not added to `browser.log`; call `read_logs` alongside this flow when the
124
492
  running app posts browser logs to the backend collector. The collector accepts local-loopback
125
493
  traffic before login; devices or remote browsers must send the app's normal authentication.
@@ -133,12 +501,12 @@ terreno web http://localhost:8082 \
133
501
  --screenshot /opt/cursor/artifacts/save-result.png
134
502
  ```
135
503
 
136
- ## 5. Verify the native fix in the running app
504
+ ### Verify a native fix in the running app
137
505
 
138
506
  Navigation and arbitrary evaluation are disabled by default. Opt in only for local debugging:
139
507
 
140
508
  ```bash
141
- TERRENO_MCP_EVAL=1 bunx terreno-mcp-local
509
+ TERRENO_MCP_EVAL=1 bunx -p @terreno/mcp terreno-mcp-local
142
510
  ```
143
511
 
144
512
  Then call `navigate` with `{"path":"/profile"}` or run:
@@ -147,8 +515,8 @@ Then call `navigate` with `{"path":"/profile"}` or run:
147
515
  TERRENO_MCP_EVAL=1 terreno navigate /profile
148
516
  ```
149
517
 
150
- Confirm the target screen renders, then call `last_error` again. A successful fix has no new
151
- error-level entry after the navigation timestamp.
518
+ Confirm the target screen renders, then call `read_logs` with `level: "error"` and `since` set to
519
+ the navigation time. A successful fix returns no entries.
152
520
 
153
521
  ## Connection problems
154
522
 
@@ -158,3 +526,5 @@ error-level entry after the navigation timestamp.
158
526
  - Set `TERRENO_METRO_URL` if Metro is not on the frontend script's `--port`.
159
527
  - Close React Native DevTools when Hermes reports another debugger connection.
160
528
  - Use only `backend,browser` sources when Metro is intentionally stopped.
529
+ - Expect `No debuggable Hermes/React Native target` from `get_client_state`, `navigate`, and
530
+ `evaluate` on web. Those tools need an iOS or Android build attached to Metro.
@@ -1,8 +1,8 @@
1
1
  # Enable MCP OAuth for a JWT app
2
2
 
3
- Let Claude.ai custom connectors, ChatGPT apps, and other remote MCP clients connect to your app's `POST /mcp` through OAuth 2.1, when your app signs users in with Terreno JWTs. Terreno runs the authorization server; your app hosts one consent screen.
3
+ Let Claude.ai custom connectors, ChatGPT apps, and other remote MCP clients connect to your app's `POST /mcp` through OAuth 2.1. Terreno runs the authorization server; your app hosts one consent screen. A Terreno JWT is the usual way to approve that screen. An app with its own session sets `req.user` first. See [Apps without a Terreno JWT](#apps-without-a-terreno-jwt).
4
4
 
5
- Use [a service token](connect-mcp-service-token.md) instead for clients that take a static Bearer key (Perplexity, Cursor, Claude Code).
5
+ Use [a service token](connect-mcp-service-token.md) when the client can only hold a static Bearer key (Perplexity, Cursor, Claude Code). For a headless client that can perform a token request, use [client credentials](#headless-clients-client-credentials) instead.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -46,6 +46,8 @@ Dynamic client registration (`POST /oauth/register`) is a separate opt-in and st
46
46
  });
47
47
  ```
48
48
 
49
+ A loopback redirect (`localhost`, `127.0.0.1`, or `[::1]`) matches any port. Register one URI, for example `http://127.0.0.1:33418/callback`, and a native client can send `http://127.0.0.1:<ephemeral>/callback` with the same scheme, host, path, and query. The authorization code is sent to the port the client used. `localhost` and `127.0.0.1` do not match each other. Every other redirect URI must match exactly. There is no per-client opt-out. Another process on that host can listen on a different port and receive the code. PKCE `S256` is what stops that process from exchanging it.
50
+
49
51
  - **Registration on.** Turn it on only when clients you cannot pre-register must connect. Set `dynamicClientRegistration: true` and list `allowedRedirectHosts`, which limits where a registered client may send codes. Claude.ai redirects to `claude.ai`, ChatGPT to `chatgpt.com`. Use `*.example.com` for subdomains, and add `localhost` only if you want local clients with loopback `http://` redirects. Keep rate limiting on.
50
52
 
51
53
  ```typescript
@@ -88,6 +90,29 @@ The client sends the user's browser to `/oauth/authorize`. Terreno checks the cl
88
90
 
89
91
  The three endpoints are in OpenAPI (`getMcpOAuthRequest`, `approveMcpOAuthRequest`, `denyMcpOAuthRequest`), so `bun run sdk` generates hooks for them. A request expires after 10 minutes (400 `Authorization request expired`), and a decided request cannot be decided again (409). The endpoints refuse `mcp_` and OAuth bearers, so an agent cannot approve itself.
90
92
 
93
+ ### Apps without a Terreno JWT
94
+
95
+ `authenticateMiddleware()` calls `next()` when `req.user` is already set, so the consent routes do not require a JWT or a Better Auth session. Set the approving user in `addMiddleware`, which runs before these routes. Limit that to the consent paths. `GET /oauth/authorize` stays the unauthenticated redirect that starts the flow.
96
+
97
+ The user object needs `_id`. Approve issues the grant to that id and records that user's `tokenEpoch`. `userForThisRequest` must resolve a session the app has already verified, such as a signed session cookie. Do not set `req.user` from an unverified header or query parameter. That user becomes the subject of the grant.
98
+
99
+ ```typescript
100
+ new TerrenoApp({userModel: User, mcpOAuth: {consentUrl, enabled: true, issuer}})
101
+ .addMiddleware(async (req, _res, next) => {
102
+ if (!req.path.startsWith("/oauth/authorize/")) {
103
+ next();
104
+ return;
105
+ }
106
+ const user = await userForThisRequest(req);
107
+ if (user) {
108
+ req.user = user;
109
+ }
110
+ next();
111
+ });
112
+ ```
113
+
114
+ Call `GET /oauth/authorize/<request>` and `POST .../approve` or `.../deny` with no `Authorization` header. `mcp_` and `mcpat_` bearers are still rejected on those routes.
115
+
91
116
  ## 3. Connect a client
92
117
 
93
118
  Give the client the MCP URL, `https://api.example.com/mcp`. It discovers the rest:
@@ -98,6 +123,32 @@ Give the client the MCP URL, `https://api.example.com/mcp`. It discovers the res
98
123
 
99
124
  For Claude.ai: **Settings → Connectors → Add custom connector**, paste the MCP URL, and connect. For ChatGPT: create a connector with the MCP URL and choose OAuth.
100
125
 
126
+ ## Headless clients (client credentials)
127
+
128
+ A sandbox or another server has no browser, so it cannot run the authorization-code flow. Create a confidential client bound to a service-account user. The helper returns the secret once. Store it, then exchange it at `POST /oauth/token`. Dynamic registration stays public-only and still rejects client secrets.
129
+
130
+ ```typescript
131
+ import {McpOAuthClient} from "@terreno/api";
132
+
133
+ const {client, clientSecret} = await McpOAuthClient.issueConfidential({
134
+ clientName: "Sandbox agent",
135
+ tokenEndpointAuthMethod: "client_secret_basic", // or "client_secret_post"
136
+ userId: serviceAccount._id,
137
+ });
138
+ ```
139
+
140
+ `client_secret_basic` sends `Authorization: Basic` with the form-encoded `client_id:client_secret`. `client_secret_post` sends `client_id` and `client_secret` in the form body. The client must use the method it was created with.
141
+
142
+ ```bash
143
+ # Form-encode the id and secret before base64. A raw colon join breaks when either value contains '%' or ':'.
144
+ AUTH=$(node -e 'const [id, secret] = process.argv.slice(1); process.stdout.write(Buffer.from(`${encodeURIComponent(id)}:${encodeURIComponent(secret)}`).toString("base64"))' "$CLIENT_ID" "$CLIENT_SECRET")
145
+ curl -X POST https://api.example.com/oauth/token \
146
+ -H "Authorization: Basic $AUTH" \
147
+ -d grant_type=client_credentials
148
+ ```
149
+
150
+ The response is an `mcpat_…` access token and no refresh token. It acts as `userId`, with `credential: "oauth"`. Disabling that user, or bumping `tokenEpoch`, ends the token the same way it ends an authorization-code token. `McpOAuthClient.revoke(clientId)` ends every token that client holds, and a revoked client cannot start a new authorization or token grant. `deleteOne`, `findOneAndDelete`, and `deleteMany` do the same. `bulkWrite` skips those hooks, so use `revoke` when a bulk write removes clients.
151
+
101
152
  ## 4. Check it
102
153
 
103
154
  ```bash
@@ -128,7 +179,7 @@ Refresh tokens rotate on every use. If an old refresh token comes back, Terreno
128
179
 
129
180
  ## Security notes
130
181
 
131
- - Only public clients with PKCE `S256` are supported. `plain` PKCE and client secrets are rejected.
182
+ - Public clients use PKCE `S256` only. `plain` PKCE is rejected. Confidential clients use `client_credentials` with `client_secret_basic` or `client_secret_post`. `private_key_jwt` is not supported. Dynamic registration cannot create a confidential client.
132
183
  - Tokens, codes, and refresh tokens are stored as SHA-256 hashes.
133
184
  - Registration, authorize, token, and revoke count against the `auth` [rate-limit](rate-limiting.md) bucket. Turn rate limiting on in production, since registration is unauthenticated.
134
185
  - If a browser-based client (such as the MCP Inspector) runs discovery from another origin, allow that origin in `corsOrigin`.