@raybase/core 0.0.2 → 0.3.3

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 (435) hide show
  1. package/LICENSE +21 -0
  2. package/dist/.admin-build/AdminApp.css +2 -0
  3. package/dist/.admin-build/AdminApp.js +77 -0
  4. package/dist/.admin-docs-build/AdminDocsApp.css +2 -0
  5. package/dist/.admin-docs-build/AdminDocsApp.js +45 -0
  6. package/dist/actions.d.ts +347 -0
  7. package/dist/actions.d.ts.map +1 -0
  8. package/dist/actions.js +661 -0
  9. package/dist/actions.js.map +1 -0
  10. package/dist/admin-assets/admin.7738f50720a8ed7b.css +2 -0
  11. package/dist/admin-assets/admin.8678cb61db34dc81.js +77 -0
  12. package/dist/admin-boot.d.ts +94 -0
  13. package/dist/admin-boot.d.ts.map +1 -0
  14. package/dist/admin-boot.js +139 -0
  15. package/dist/admin-boot.js.map +1 -0
  16. package/dist/admin-docs-content.d.ts +95 -0
  17. package/dist/admin-docs-content.d.ts.map +1 -0
  18. package/dist/admin-docs-content.js +463 -0
  19. package/dist/admin-docs-content.js.map +1 -0
  20. package/dist/admin-docs-ui.d.ts +11 -0
  21. package/dist/admin-docs-ui.d.ts.map +1 -0
  22. package/dist/admin-docs-ui.js +23 -0
  23. package/dist/admin-docs-ui.js.map +1 -0
  24. package/dist/admin-kit.d.ts +29 -0
  25. package/dist/admin-kit.js +1557 -0
  26. package/dist/admin-role.d.ts +8 -0
  27. package/dist/admin-role.d.ts.map +1 -0
  28. package/dist/admin-role.js +22 -0
  29. package/dist/admin-role.js.map +1 -0
  30. package/dist/admin-ui.d.ts +3 -0
  31. package/dist/admin-ui.d.ts.map +1 -0
  32. package/dist/admin-ui.js +16 -0
  33. package/dist/admin-ui.js.map +1 -0
  34. package/dist/agent-ai-chat.d.ts +127 -0
  35. package/dist/agent-ai-chat.d.ts.map +1 -0
  36. package/dist/agent-ai-chat.js +5975 -0
  37. package/dist/agent-ai-chat.js.map +1 -0
  38. package/dist/agent-chapter-archive.d.ts +214 -0
  39. package/dist/agent-chapter-archive.d.ts.map +1 -0
  40. package/dist/agent-chapter-archive.js +696 -0
  41. package/dist/agent-chapter-archive.js.map +1 -0
  42. package/dist/agent-chapter-search.d.ts +116 -0
  43. package/dist/agent-chapter-search.d.ts.map +1 -0
  44. package/dist/agent-chapter-search.js +605 -0
  45. package/dist/agent-chapter-search.js.map +1 -0
  46. package/dist/agent-chapter-tools.d.ts +45 -0
  47. package/dist/agent-chapter-tools.d.ts.map +1 -0
  48. package/dist/agent-chapter-tools.js +344 -0
  49. package/dist/agent-chapter-tools.js.map +1 -0
  50. package/dist/agent-chapters.d.ts +228 -0
  51. package/dist/agent-chapters.d.ts.map +1 -0
  52. package/dist/agent-chapters.js +737 -0
  53. package/dist/agent-chapters.js.map +1 -0
  54. package/dist/agent-data-parts.d.ts +167 -0
  55. package/dist/agent-data-parts.d.ts.map +1 -0
  56. package/dist/agent-data-parts.js +68 -0
  57. package/dist/agent-data-parts.js.map +1 -0
  58. package/dist/agent-errors.d.ts +41 -0
  59. package/dist/agent-errors.d.ts.map +1 -0
  60. package/dist/agent-errors.js +191 -0
  61. package/dist/agent-errors.js.map +1 -0
  62. package/dist/agent-follow-up-store.d.ts +273 -0
  63. package/dist/agent-follow-up-store.d.ts.map +1 -0
  64. package/dist/agent-follow-up-store.js +703 -0
  65. package/dist/agent-follow-up-store.js.map +1 -0
  66. package/dist/agent-follow-ups.d.ts +314 -0
  67. package/dist/agent-follow-ups.d.ts.map +1 -0
  68. package/dist/agent-follow-ups.js +840 -0
  69. package/dist/agent-follow-ups.js.map +1 -0
  70. package/dist/agent-memory.d.ts +323 -0
  71. package/dist/agent-memory.d.ts.map +1 -0
  72. package/dist/agent-memory.js +1270 -0
  73. package/dist/agent-memory.js.map +1 -0
  74. package/dist/agent-message-parts.d.ts +58 -0
  75. package/dist/agent-message-parts.d.ts.map +1 -0
  76. package/dist/agent-message-parts.js +163 -0
  77. package/dist/agent-message-parts.js.map +1 -0
  78. package/dist/agent-remote-mcp-credentials.d.ts +154 -0
  79. package/dist/agent-remote-mcp-credentials.d.ts.map +1 -0
  80. package/dist/agent-remote-mcp-credentials.js +287 -0
  81. package/dist/agent-remote-mcp-credentials.js.map +1 -0
  82. package/dist/agent-remote-mcp-crypto.d.ts +32 -0
  83. package/dist/agent-remote-mcp-crypto.d.ts.map +1 -0
  84. package/dist/agent-remote-mcp-crypto.js +101 -0
  85. package/dist/agent-remote-mcp-crypto.js.map +1 -0
  86. package/dist/agent-remote-mcp-install.d.ts +299 -0
  87. package/dist/agent-remote-mcp-install.d.ts.map +1 -0
  88. package/dist/agent-remote-mcp-install.js +785 -0
  89. package/dist/agent-remote-mcp-install.js.map +1 -0
  90. package/dist/agent-remote-mcp-oauth.d.ts +73 -0
  91. package/dist/agent-remote-mcp-oauth.d.ts.map +1 -0
  92. package/dist/agent-remote-mcp-oauth.js +344 -0
  93. package/dist/agent-remote-mcp-oauth.js.map +1 -0
  94. package/dist/agent-remote-mcp-transport.d.ts +46 -0
  95. package/dist/agent-remote-mcp-transport.d.ts.map +1 -0
  96. package/dist/agent-remote-mcp-transport.js +225 -0
  97. package/dist/agent-remote-mcp-transport.js.map +1 -0
  98. package/dist/agent-remote-mcp.d.ts +147 -0
  99. package/dist/agent-remote-mcp.d.ts.map +1 -0
  100. package/dist/agent-remote-mcp.js +681 -0
  101. package/dist/agent-remote-mcp.js.map +1 -0
  102. package/dist/agent-room-redaction.d.ts +66 -0
  103. package/dist/agent-room-redaction.d.ts.map +1 -0
  104. package/dist/agent-room-redaction.js +264 -0
  105. package/dist/agent-room-redaction.js.map +1 -0
  106. package/dist/agent-rooms.d.ts +209 -0
  107. package/dist/agent-rooms.d.ts.map +1 -0
  108. package/dist/agent-rooms.js +244 -0
  109. package/dist/agent-rooms.js.map +1 -0
  110. package/dist/agent-runtime.d.ts +541 -0
  111. package/dist/agent-runtime.d.ts.map +1 -0
  112. package/dist/agent-runtime.js +4260 -0
  113. package/dist/agent-runtime.js.map +1 -0
  114. package/dist/agent-skill-archive.d.ts +20 -0
  115. package/dist/agent-skill-archive.d.ts.map +1 -0
  116. package/dist/agent-skill-archive.js +316 -0
  117. package/dist/agent-skill-archive.js.map +1 -0
  118. package/dist/agent-skill-discovery.d.ts +96 -0
  119. package/dist/agent-skill-discovery.d.ts.map +1 -0
  120. package/dist/agent-skill-discovery.js +464 -0
  121. package/dist/agent-skill-discovery.js.map +1 -0
  122. package/dist/agent-skill-format.d.ts +57 -0
  123. package/dist/agent-skill-format.d.ts.map +1 -0
  124. package/dist/agent-skill-format.js +238 -0
  125. package/dist/agent-skill-format.js.map +1 -0
  126. package/dist/agent-skill-import.d.ts +64 -0
  127. package/dist/agent-skill-import.d.ts.map +1 -0
  128. package/dist/agent-skill-import.js +295 -0
  129. package/dist/agent-skill-import.js.map +1 -0
  130. package/dist/agent-skill-publish.d.ts +51 -0
  131. package/dist/agent-skill-publish.d.ts.map +1 -0
  132. package/dist/agent-skill-publish.js +136 -0
  133. package/dist/agent-skill-publish.js.map +1 -0
  134. package/dist/agent-skill-store.d.ts +252 -0
  135. package/dist/agent-skill-store.d.ts.map +1 -0
  136. package/dist/agent-skill-store.js +514 -0
  137. package/dist/agent-skill-store.js.map +1 -0
  138. package/dist/agent-skills.d.ts +85 -0
  139. package/dist/agent-skills.d.ts.map +1 -0
  140. package/dist/agent-skills.js +291 -0
  141. package/dist/agent-skills.js.map +1 -0
  142. package/dist/agent-transcript-pager.d.ts +77 -0
  143. package/dist/agent-transcript-pager.d.ts.map +1 -0
  144. package/dist/agent-transcript-pager.js +210 -0
  145. package/dist/agent-transcript-pager.js.map +1 -0
  146. package/dist/agent-user-data-parts.d.ts +20 -0
  147. package/dist/agent-user-data-parts.d.ts.map +1 -0
  148. package/dist/agent-user-data-parts.js +161 -0
  149. package/dist/agent-user-data-parts.js.map +1 -0
  150. package/dist/agent-user-transcript.d.ts +46 -0
  151. package/dist/agent-user-transcript.d.ts.map +1 -0
  152. package/dist/agent-user-transcript.js +106 -0
  153. package/dist/agent-user-transcript.js.map +1 -0
  154. package/dist/agent-voice-notes.d.ts +15 -0
  155. package/dist/agent-voice-notes.d.ts.map +1 -0
  156. package/dist/agent-voice-notes.js +17 -0
  157. package/dist/agent-voice-notes.js.map +1 -0
  158. package/dist/agent-wakes.d.ts +213 -0
  159. package/dist/agent-wakes.d.ts.map +1 -0
  160. package/dist/agent-wakes.js +534 -0
  161. package/dist/agent-wakes.js.map +1 -0
  162. package/dist/agent.d.ts +1780 -0
  163. package/dist/agent.d.ts.map +1 -0
  164. package/dist/agent.js +3123 -0
  165. package/dist/agent.js.map +1 -0
  166. package/dist/ai.d.ts +583 -0
  167. package/dist/ai.d.ts.map +1 -0
  168. package/dist/ai.js +1858 -0
  169. package/dist/ai.js.map +1 -0
  170. package/dist/app-manifest.d.ts +255 -0
  171. package/dist/app-manifest.d.ts.map +1 -0
  172. package/dist/app-manifest.js +372 -0
  173. package/dist/app-manifest.js.map +1 -0
  174. package/dist/app-users.d.ts +30 -0
  175. package/dist/app-users.d.ts.map +1 -0
  176. package/dist/app-users.js +91 -0
  177. package/dist/app-users.js.map +1 -0
  178. package/dist/audit-archive.d.ts +85 -0
  179. package/dist/audit-archive.d.ts.map +1 -0
  180. package/dist/audit-archive.js +178 -0
  181. package/dist/audit-archive.js.map +1 -0
  182. package/dist/audit.d.ts +54 -0
  183. package/dist/audit.d.ts.map +1 -0
  184. package/dist/audit.js +95 -0
  185. package/dist/audit.js.map +1 -0
  186. package/dist/auth-logger.d.ts +28 -0
  187. package/dist/auth-logger.d.ts.map +1 -0
  188. package/dist/auth-logger.js +53 -0
  189. package/dist/auth-logger.js.map +1 -0
  190. package/dist/auth-password-secret.d.ts +59 -0
  191. package/dist/auth-password-secret.d.ts.map +1 -0
  192. package/dist/auth-password-secret.js +409 -0
  193. package/dist/auth-password-secret.js.map +1 -0
  194. package/dist/auth-schema.d.ts +120 -0
  195. package/dist/auth-schema.d.ts.map +1 -0
  196. package/dist/auth-schema.js +285 -0
  197. package/dist/auth-schema.js.map +1 -0
  198. package/dist/auth.d.ts +321 -0
  199. package/dist/auth.d.ts.map +1 -0
  200. package/dist/auth.js +1562 -0
  201. package/dist/auth.js.map +1 -0
  202. package/dist/backup-archive.d.ts +257 -0
  203. package/dist/backup-archive.d.ts.map +1 -0
  204. package/dist/backup-archive.js +2068 -0
  205. package/dist/backup-archive.js.map +1 -0
  206. package/dist/backup-crypto.d.ts +313 -0
  207. package/dist/backup-crypto.d.ts.map +1 -0
  208. package/dist/backup-crypto.js +693 -0
  209. package/dist/backup-crypto.js.map +1 -0
  210. package/dist/backup-format.d.ts +200 -0
  211. package/dist/backup-format.d.ts.map +1 -0
  212. package/dist/backup-format.js +1247 -0
  213. package/dist/backup-format.js.map +1 -0
  214. package/dist/cli.d.ts +9 -0
  215. package/dist/cli.d.ts.map +1 -0
  216. package/dist/cli.js +15 -0
  217. package/dist/cli.js.map +1 -0
  218. package/dist/clock.d.ts +35 -0
  219. package/dist/clock.d.ts.map +1 -0
  220. package/dist/clock.js +83 -0
  221. package/dist/clock.js.map +1 -0
  222. package/dist/data-client.d.ts +187 -0
  223. package/dist/data-client.d.ts.map +1 -0
  224. package/dist/data-client.js +590 -0
  225. package/dist/data-client.js.map +1 -0
  226. package/dist/data-secrets.d.ts +164 -0
  227. package/dist/data-secrets.d.ts.map +1 -0
  228. package/dist/data-secrets.js +383 -0
  229. package/dist/data-secrets.js.map +1 -0
  230. package/dist/db.d.ts +453 -0
  231. package/dist/db.d.ts.map +1 -0
  232. package/dist/db.js +324 -0
  233. package/dist/db.js.map +1 -0
  234. package/dist/dev-activity.d.ts +48 -0
  235. package/dist/dev-activity.d.ts.map +1 -0
  236. package/dist/dev-activity.js +50 -0
  237. package/dist/dev-activity.js.map +1 -0
  238. package/dist/do.d.ts +429 -0
  239. package/dist/do.d.ts.map +1 -0
  240. package/dist/do.js +4526 -0
  241. package/dist/do.js.map +1 -0
  242. package/dist/email.d.ts +137 -0
  243. package/dist/email.d.ts.map +1 -0
  244. package/dist/email.js +527 -0
  245. package/dist/email.js.map +1 -0
  246. package/dist/generated/admin-assets.d.ts +16 -0
  247. package/dist/generated/admin-assets.d.ts.map +1 -0
  248. package/dist/generated/admin-assets.js +16 -0
  249. package/dist/generated/admin-assets.js.map +1 -0
  250. package/dist/generated/admin-docs-assets.d.ts +3 -0
  251. package/dist/generated/admin-docs-assets.d.ts.map +1 -0
  252. package/dist/generated/admin-docs-assets.js +3 -0
  253. package/dist/generated/admin-docs-assets.js.map +1 -0
  254. package/dist/handoff-consumer.d.ts +79 -0
  255. package/dist/handoff-consumer.d.ts.map +1 -0
  256. package/dist/handoff-consumer.js +300 -0
  257. package/dist/handoff-consumer.js.map +1 -0
  258. package/dist/handoff.d.ts +70 -0
  259. package/dist/handoff.d.ts.map +1 -0
  260. package/dist/handoff.js +228 -0
  261. package/dist/handoff.js.map +1 -0
  262. package/dist/host-auth.d.ts +47 -0
  263. package/dist/host-auth.d.ts.map +1 -0
  264. package/dist/host-auth.js +59 -0
  265. package/dist/host-auth.js.map +1 -0
  266. package/dist/host-user.d.ts +15 -0
  267. package/dist/host-user.d.ts.map +1 -0
  268. package/dist/host-user.js +23 -0
  269. package/dist/host-user.js.map +1 -0
  270. package/dist/html.d.ts +35 -0
  271. package/dist/html.d.ts.map +1 -0
  272. package/dist/html.js +634 -0
  273. package/dist/html.js.map +1 -0
  274. package/dist/http-error.d.ts +13 -0
  275. package/dist/http-error.d.ts.map +1 -0
  276. package/dist/http-error.js +15 -0
  277. package/dist/http-error.js.map +1 -0
  278. package/dist/imports.d.ts +219 -0
  279. package/dist/imports.d.ts.map +1 -0
  280. package/dist/imports.js +571 -0
  281. package/dist/imports.js.map +1 -0
  282. package/dist/index.d.ts +78 -0
  283. package/dist/index.d.ts.map +1 -0
  284. package/dist/index.js +76 -0
  285. package/dist/index.js.map +1 -0
  286. package/dist/inference-tape.d.ts +64 -0
  287. package/dist/inference-tape.d.ts.map +1 -0
  288. package/dist/inference-tape.js +278 -0
  289. package/dist/inference-tape.js.map +1 -0
  290. package/dist/internal.d.ts +15 -0
  291. package/dist/internal.d.ts.map +1 -0
  292. package/dist/internal.js +48 -0
  293. package/dist/internal.js.map +1 -0
  294. package/dist/jobs.d.ts +796 -0
  295. package/dist/jobs.d.ts.map +1 -0
  296. package/dist/jobs.js +538 -0
  297. package/dist/jobs.js.map +1 -0
  298. package/dist/json-schema.d.ts +91 -0
  299. package/dist/json-schema.d.ts.map +1 -0
  300. package/dist/json-schema.js +884 -0
  301. package/dist/json-schema.js.map +1 -0
  302. package/dist/knowledge.d.ts +33 -0
  303. package/dist/knowledge.d.ts.map +1 -0
  304. package/dist/knowledge.js +348 -0
  305. package/dist/knowledge.js.map +1 -0
  306. package/dist/local-dev-chrome.d.ts +29 -0
  307. package/dist/local-dev-chrome.d.ts.map +1 -0
  308. package/dist/local-dev-chrome.js +514 -0
  309. package/dist/local-dev-chrome.js.map +1 -0
  310. package/dist/local-dev-personas.d.ts +15 -0
  311. package/dist/local-dev-personas.d.ts.map +1 -0
  312. package/dist/local-dev-personas.js +46 -0
  313. package/dist/local-dev-personas.js.map +1 -0
  314. package/dist/mcp.d.ts +567 -0
  315. package/dist/mcp.d.ts.map +1 -0
  316. package/dist/mcp.js +2480 -0
  317. package/dist/mcp.js.map +1 -0
  318. package/dist/modules.d.ts +145 -0
  319. package/dist/modules.d.ts.map +1 -0
  320. package/dist/modules.js +164 -0
  321. package/dist/modules.js.map +1 -0
  322. package/dist/notification-client.d.ts +94 -0
  323. package/dist/notification-client.d.ts.map +1 -0
  324. package/dist/notification-client.js +127 -0
  325. package/dist/notification-client.js.map +1 -0
  326. package/dist/notifications.d.ts +447 -0
  327. package/dist/notifications.d.ts.map +1 -0
  328. package/dist/notifications.js +1625 -0
  329. package/dist/notifications.js.map +1 -0
  330. package/dist/operator-data-query.d.ts +117 -0
  331. package/dist/operator-data-query.d.ts.map +1 -0
  332. package/dist/operator-data-query.js +431 -0
  333. package/dist/operator-data-query.js.map +1 -0
  334. package/dist/operator-mcp.d.ts +63 -0
  335. package/dist/operator-mcp.d.ts.map +1 -0
  336. package/dist/operator-mcp.js +2828 -0
  337. package/dist/operator-mcp.js.map +1 -0
  338. package/dist/outbound-https.d.ts +24 -0
  339. package/dist/outbound-https.d.ts.map +1 -0
  340. package/dist/outbound-https.js +172 -0
  341. package/dist/outbound-https.js.map +1 -0
  342. package/dist/pitr.d.ts +60 -0
  343. package/dist/pitr.d.ts.map +1 -0
  344. package/dist/pitr.js +94 -0
  345. package/dist/pitr.js.map +1 -0
  346. package/dist/platform.d.ts +199 -0
  347. package/dist/platform.d.ts.map +1 -0
  348. package/dist/platform.js +196 -0
  349. package/dist/platform.js.map +1 -0
  350. package/dist/policies.d.ts +115 -0
  351. package/dist/policies.d.ts.map +1 -0
  352. package/dist/policies.js +158 -0
  353. package/dist/policies.js.map +1 -0
  354. package/dist/principal.d.ts +143 -0
  355. package/dist/principal.d.ts.map +1 -0
  356. package/dist/principal.js +174 -0
  357. package/dist/principal.js.map +1 -0
  358. package/dist/realtime.d.ts +280 -0
  359. package/dist/realtime.d.ts.map +1 -0
  360. package/dist/realtime.js +578 -0
  361. package/dist/realtime.js.map +1 -0
  362. package/dist/retrieval.d.ts +263 -0
  363. package/dist/retrieval.d.ts.map +1 -0
  364. package/dist/retrieval.js +602 -0
  365. package/dist/retrieval.js.map +1 -0
  366. package/dist/runtime-error-client.d.ts +10 -0
  367. package/dist/runtime-error-client.d.ts.map +1 -0
  368. package/dist/runtime-error-client.js +70 -0
  369. package/dist/runtime-error-client.js.map +1 -0
  370. package/dist/runtime-errors.d.ts +180 -0
  371. package/dist/runtime-errors.d.ts.map +1 -0
  372. package/dist/runtime-errors.js +572 -0
  373. package/dist/runtime-errors.js.map +1 -0
  374. package/dist/schema.d.ts +434 -0
  375. package/dist/schema.d.ts.map +1 -0
  376. package/dist/schema.js +232 -0
  377. package/dist/schema.js.map +1 -0
  378. package/dist/search.d.ts +216 -0
  379. package/dist/search.d.ts.map +1 -0
  380. package/dist/search.js +785 -0
  381. package/dist/search.js.map +1 -0
  382. package/dist/settings.d.ts +191 -0
  383. package/dist/settings.d.ts.map +1 -0
  384. package/dist/settings.js +478 -0
  385. package/dist/settings.js.map +1 -0
  386. package/dist/sql.d.ts +7 -0
  387. package/dist/sql.d.ts.map +1 -0
  388. package/dist/sql.js +30 -0
  389. package/dist/sql.js.map +1 -0
  390. package/dist/storage-client.d.ts +69 -0
  391. package/dist/storage-client.d.ts.map +1 -0
  392. package/dist/storage-client.js +469 -0
  393. package/dist/storage-client.js.map +1 -0
  394. package/dist/storage.d.ts +21 -0
  395. package/dist/storage.d.ts.map +1 -0
  396. package/dist/storage.js +82 -0
  397. package/dist/storage.js.map +1 -0
  398. package/dist/subject-domains.d.ts +15 -0
  399. package/dist/subject-domains.d.ts.map +1 -0
  400. package/dist/subject-domains.js +38 -0
  401. package/dist/subject-domains.js.map +1 -0
  402. package/dist/support.d.ts +132 -0
  403. package/dist/support.d.ts.map +1 -0
  404. package/dist/support.js +483 -0
  405. package/dist/support.js.map +1 -0
  406. package/dist/system-tables.d.ts +12 -0
  407. package/dist/system-tables.d.ts.map +1 -0
  408. package/dist/system-tables.js +225 -0
  409. package/dist/system-tables.js.map +1 -0
  410. package/dist/test.d.ts +57 -0
  411. package/dist/test.d.ts.map +1 -0
  412. package/dist/test.js +454 -0
  413. package/dist/test.js.map +1 -0
  414. package/dist/vite.d.ts +82 -0
  415. package/dist/vite.d.ts.map +1 -0
  416. package/dist/vite.js +82 -0
  417. package/dist/vite.js.map +1 -0
  418. package/dist/webpush-crypto.d.ts +39 -0
  419. package/dist/webpush-crypto.d.ts.map +1 -0
  420. package/dist/webpush-crypto.js +213 -0
  421. package/dist/webpush-crypto.js.map +1 -0
  422. package/dist/webpush-private.d.ts +11 -0
  423. package/dist/webpush-private.d.ts.map +1 -0
  424. package/dist/webpush-private.js +11 -0
  425. package/dist/webpush-private.js.map +1 -0
  426. package/dist/webpush.d.ts +86 -0
  427. package/dist/webpush.d.ts.map +1 -0
  428. package/dist/webpush.js +375 -0
  429. package/dist/webpush.js.map +1 -0
  430. package/dist/worker.d.ts +185 -0
  431. package/dist/worker.d.ts.map +1 -0
  432. package/dist/worker.js +13021 -0
  433. package/dist/worker.js.map +1 -0
  434. package/package.json +161 -8
  435. package/README.md +0 -3
package/dist/mcp.js ADDED
@@ -0,0 +1,2480 @@
1
+ import { z } from "zod";
2
+ import { withHostUserRoleCeiling } from "./host-user.js";
3
+ import { redactSecretColumnKeys, secretColumnKeys } from "./data-secrets.js";
4
+ import { audit, base64url, normalizeRoles } from "./auth.js";
5
+ import { hasAdminAccess, normalizeStoredAdminRole } from "./admin-role.js";
6
+ import { inputSchemaForTable } from "./data-client.js";
7
+ import { createRaybaseDb, dataStub } from "./db.js";
8
+ import { sha256Hex } from "./internal.js";
9
+ import { jsonSchemaFor } from "./json-schema.js";
10
+ import { searchIndex, updateSearchForDataChange } from "./search.js";
11
+ import { id, longText, raybaseSystemTable, text } from "./schema.js";
12
+ import { randomId } from "./sql.js";
13
+ import { wallNowIso as nowIso, wallNowMs } from "./clock.js";
14
+ import { ensureManifestTables } from "./system-tables.js";
15
+ export const mcpAppsExtensionId = "io.modelcontextprotocol/ui";
16
+ export const mcpAppResourceMimeType = "text/html;profile=mcp-app";
17
+ const jsonHeaders = { "content-type": "application/json; charset=utf-8" };
18
+ const defaultAccessTokenTtlSeconds = 60 * 60;
19
+ const defaultRefreshTokenTtlSeconds = 60 * 60 * 24 * 30;
20
+ const defaultGrantTtlSeconds = 60 * 60 * 24 * 90;
21
+ // Best-effort per-isolate limits. Durable/fleet-wide abuse controls belong at
22
+ // the edge or in a future persistent limiter.
23
+ const rateLimitWindows = new Map();
24
+ export const raybaseMcpSystemTables = [
25
+ raybaseSystemTable("_oauth_clients", {
26
+ id: text({ primaryKey: true, required: true }),
27
+ clientId: text({ required: true, unique: true }),
28
+ clientName: text({ required: true }),
29
+ redirectUris: longText({ required: true }),
30
+ metadata: longText(),
31
+ createdAt: text({ required: true }),
32
+ revokedAt: text(),
33
+ // Confidential clients (client_credentials). Public PKCE clients leave
34
+ // these null; the secret is stored only as a SHA-256 hash.
35
+ clientSecretHash: longText(),
36
+ tokenEndpointAuthMethod: text(),
37
+ grantTypes: longText(),
38
+ scopes: longText(),
39
+ resource: longText(),
40
+ grantId: text(),
41
+ createdByUserId: text(),
42
+ organizationId: text()
43
+ }, { indexes: [["clientId"], ["createdByUserId"], ["grantId"]] }),
44
+ raybaseSystemTable("_oauth_authorization_codes", {
45
+ id: id(),
46
+ codeHash: longText({ required: true, unique: true }),
47
+ clientId: text({ required: true }),
48
+ redirectUri: longText({ required: true }),
49
+ scope: longText({ required: true }),
50
+ resource: longText({ required: true }),
51
+ codeChallenge: longText({ required: true }),
52
+ codeChallengeMethod: text({ required: true }),
53
+ grantId: text({ required: true }),
54
+ userId: text({ required: true }),
55
+ hostAccount: text(),
56
+ state: longText(),
57
+ expiresAt: text({ required: true }),
58
+ consumedAt: text(),
59
+ createdAt: text({ required: true })
60
+ }, { indexes: [["clientId"], ["grantId"], ["userId"]] }),
61
+ raybaseSystemTable("_oauth_access_tokens", {
62
+ id: id(),
63
+ tokenHash: longText({ required: true, unique: true }),
64
+ grantId: text({ required: true }),
65
+ clientId: text({ required: true }),
66
+ userId: text({ required: true }),
67
+ resource: longText({ required: true }),
68
+ scope: longText({ required: true }),
69
+ expiresAt: text({ required: true }),
70
+ revokedAt: text(),
71
+ createdAt: text({ required: true }),
72
+ lastUsedAt: text()
73
+ }, { indexes: [["grantId"], ["clientId"], ["userId"]] }),
74
+ raybaseSystemTable("_oauth_refresh_tokens", {
75
+ id: id(),
76
+ tokenHash: longText({ required: true, unique: true }),
77
+ grantId: text({ required: true }),
78
+ clientId: text({ required: true }),
79
+ userId: text({ required: true }),
80
+ scope: longText({ required: true }),
81
+ expiresAt: text({ required: true }),
82
+ revokedAt: text(),
83
+ replacedBy: text(),
84
+ createdAt: text({ required: true })
85
+ }, { indexes: [["grantId"], ["clientId"], ["userId"]] }),
86
+ raybaseSystemTable("_mcp_grants", {
87
+ id: id(),
88
+ subjectType: text({ required: true }),
89
+ subjectId: text({ required: true }),
90
+ userId: text(),
91
+ hostAccount: text(),
92
+ clientId: text({ required: true }),
93
+ clientName: text({ required: true }),
94
+ scopes: longText({ required: true }),
95
+ resource: longText({ required: true }),
96
+ createdAt: text({ required: true }),
97
+ expiresAt: text(),
98
+ revokedAt: text(),
99
+ lastUsedAt: text(),
100
+ organizationId: text(),
101
+ grantType: text()
102
+ }, { indexes: [["clientId"], ["userId"], ["hostAccount"]] }),
103
+ raybaseSystemTable("_mcp_grant_events", {
104
+ id: id(),
105
+ grantId: text({ required: true }),
106
+ clientId: text({ required: true }),
107
+ action: text({ required: true }),
108
+ toolName: text(),
109
+ metadata: longText(),
110
+ createdAt: text({ required: true })
111
+ }, { indexes: [["grantId"], ["clientId"], ["action"]] }),
112
+ raybaseSystemTable("_mcp_pending_authorizations", {
113
+ id: id(),
114
+ clientId: text({ required: true }),
115
+ redirectUri: longText({ required: true }),
116
+ scope: longText({ required: true }),
117
+ resource: longText({ required: true }),
118
+ state: longText(),
119
+ codeChallenge: longText({ required: true }),
120
+ hostAccount: text(),
121
+ expiresAt: text({ required: true }),
122
+ createdAt: text({ required: true })
123
+ }, { indexes: [["clientId"], ["hostAccount"]] }),
124
+ raybaseSystemTable("_mcp_approval_tickets", {
125
+ id: id(),
126
+ ticketHash: longText({ required: true, unique: true }),
127
+ pendingAuthId: text({ required: true }),
128
+ hostAccount: text({ required: true }),
129
+ clientId: text({ required: true }),
130
+ scope: longText({ required: true }),
131
+ expiresAt: text({ required: true }),
132
+ consumedAt: text(),
133
+ createdAt: text({ required: true })
134
+ }, { indexes: [["pendingAuthId"], ["hostAccount"], ["clientId"]] })
135
+ ];
136
+ async function ensureMcpSystemTables(db) {
137
+ await ensureManifestTables(db, raybaseMcpSystemTables);
138
+ }
139
+ function asRecord(value) {
140
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
141
+ }
142
+ function asString(value) {
143
+ return typeof value === "string" ? value : "";
144
+ }
145
+ function isRecordValue(value) {
146
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
147
+ }
148
+ function splitScopes(value) {
149
+ const raw = Array.isArray(value) ? value.join(" ") : value ?? "";
150
+ return [...new Set(raw.split(/\s+/).map((scope) => scope.trim()).filter(Boolean))];
151
+ }
152
+ export function normalizeMcpAppVisibility(value) {
153
+ if (value === undefined)
154
+ return ["model", "app"];
155
+ if (!Array.isArray(value))
156
+ throw new Error("MCP Apps tool visibility must be an array");
157
+ const output = [];
158
+ for (const entry of value) {
159
+ if (entry !== "model" && entry !== "app")
160
+ throw new Error(`Unsupported MCP Apps tool visibility: ${String(entry)}`);
161
+ if (!output.includes(entry))
162
+ output.push(entry);
163
+ }
164
+ return output.length ? output : ["model", "app"];
165
+ }
166
+ export function isMcpAppResource(resource) {
167
+ return resource.uri.startsWith("ui://") || resource.mimeType === mcpAppResourceMimeType;
168
+ }
169
+ export function validateMcpAppResourceMeta(meta) {
170
+ const ui = meta?.ui;
171
+ if (ui === undefined)
172
+ return;
173
+ if (!isRecordValue(ui))
174
+ throw new Error("MCP Apps resource _meta.ui must be an object");
175
+ for (const key of ["connectDomains", "resourceDomains", "frameDomains", "baseUriDomains"]) {
176
+ if (key in ui)
177
+ throw new Error(`MCP Apps resource _meta.ui.${key} moved to _meta.ui.csp.${key}`);
178
+ }
179
+ if (ui.csp !== undefined) {
180
+ if (!isRecordValue(ui.csp))
181
+ throw new Error("MCP Apps resource _meta.ui.csp must be an object");
182
+ const csp = ui.csp;
183
+ for (const key of ["connectDomains", "resourceDomains", "frameDomains", "baseUriDomains"]) {
184
+ const value = csp[key];
185
+ if (value !== undefined && (!Array.isArray(value) || value.some((entry) => typeof entry !== "string"))) {
186
+ throw new Error(`MCP Apps resource _meta.ui.csp.${key} must be a string array`);
187
+ }
188
+ }
189
+ }
190
+ if (ui.permissions !== undefined) {
191
+ if (!isRecordValue(ui.permissions))
192
+ throw new Error("MCP Apps resource _meta.ui.permissions must be an object");
193
+ const permissions = ui.permissions;
194
+ for (const key of Object.keys(permissions)) {
195
+ if (!["camera", "microphone", "geolocation", "clipboardWrite"].includes(key))
196
+ throw new Error(`Unsupported MCP Apps resource permission: ${key}`);
197
+ const permission = permissions[key];
198
+ if (!isRecordValue(permission))
199
+ throw new Error(`MCP Apps resource permission ${key} must be an object`);
200
+ if (Object.keys(permission).length)
201
+ throw new Error(`MCP Apps resource permission ${key} must be an empty object`);
202
+ }
203
+ }
204
+ if (ui.domain !== undefined && typeof ui.domain !== "string")
205
+ throw new Error("MCP Apps resource _meta.ui.domain must be a string");
206
+ if (ui.prefersBorder !== undefined && typeof ui.prefersBorder !== "boolean")
207
+ throw new Error("MCP Apps resource _meta.ui.prefersBorder must be a boolean");
208
+ }
209
+ export function validateMcpAppResource(resource) {
210
+ if (!isMcpAppResource(resource)) {
211
+ validateMcpAppResourceMeta(resource._meta);
212
+ return;
213
+ }
214
+ if (!resource.uri.startsWith("ui://"))
215
+ throw new Error(`MCP Apps resource ${resource.name} must use a ui:// URI`);
216
+ if (resource.mimeType !== mcpAppResourceMimeType)
217
+ throw new Error(`MCP Apps resource ${resource.uri} must use MIME type ${mcpAppResourceMimeType}`);
218
+ validateMcpAppResourceMeta(resource._meta);
219
+ }
220
+ export function validateMcpAppTool(tool, resources) {
221
+ const ui = tool._meta?.ui;
222
+ if (ui === undefined)
223
+ return;
224
+ if (!isRecordValue(ui))
225
+ throw new Error(`MCP Apps tool ${tool.name} _meta.ui must be an object`);
226
+ normalizeMcpAppVisibility(ui.visibility);
227
+ if (ui.resourceUri !== undefined) {
228
+ if (typeof ui.resourceUri !== "string" || !ui.resourceUri)
229
+ throw new Error(`MCP Apps tool ${tool.name} _meta.ui.resourceUri must be a non-empty string`);
230
+ if (!resources.some((resource) => resource.uri === ui.resourceUri))
231
+ throw new Error(`MCP Apps tool ${tool.name} references missing UI resource: ${ui.resourceUri}`);
232
+ }
233
+ }
234
+ function mcpAppResourceMeta(meta) {
235
+ if (!meta)
236
+ return undefined;
237
+ validateMcpAppResourceMeta(meta);
238
+ return meta;
239
+ }
240
+ function mergeMcpResourceReadMeta(resourceMeta, readMeta) {
241
+ if (!resourceMeta && !readMeta)
242
+ return undefined;
243
+ const merged = { ...(resourceMeta ?? {}), ...(readMeta ?? {}) };
244
+ validateMcpAppResourceMeta(merged);
245
+ return merged;
246
+ }
247
+ function normalizeMcpAppResourceRead(resource, read) {
248
+ if (!isMcpAppResource(resource))
249
+ return read;
250
+ if (read.uri && read.uri !== resource.uri)
251
+ throw new Error(`MCP Apps resource read URI must match ${resource.uri}`);
252
+ if (read.mimeType && read.mimeType !== mcpAppResourceMimeType)
253
+ throw new Error(`MCP Apps resource read MIME type must be ${mcpAppResourceMimeType}`);
254
+ if (read.text === undefined && read.blob === undefined)
255
+ throw new Error(`MCP Apps resource ${resource.uri} must return text or blob content`);
256
+ const meta = mergeMcpResourceReadMeta(resource._meta, read._meta);
257
+ return {
258
+ ...read,
259
+ uri: resource.uri,
260
+ mimeType: mcpAppResourceMimeType,
261
+ ...(meta ? { _meta: meta } : {})
262
+ };
263
+ }
264
+ function toolMcpAppVisibility(tool) {
265
+ return normalizeMcpAppVisibility(tool._meta?.ui?.visibility);
266
+ }
267
+ function toolVisibleToModel(tool) {
268
+ return toolMcpAppVisibility(tool).includes("model");
269
+ }
270
+ function supportsMcpAppUi(tools, resources) {
271
+ return tools.some((tool) => Boolean(tool._meta?.ui)) || resources.some(isMcpAppResource);
272
+ }
273
+ function clientAcceptsMcpAppUi(params) {
274
+ const capabilities = asRecord(asRecord(params).capabilities);
275
+ const extensions = asRecord(capabilities.extensions);
276
+ const ui = extensions[mcpAppsExtensionId];
277
+ if (!isRecordValue(ui))
278
+ return false;
279
+ const mimeTypes = ui.mimeTypes;
280
+ return mimeTypes === undefined || (Array.isArray(mimeTypes) && mimeTypes.includes(mcpAppResourceMimeType));
281
+ }
282
+ export function withMcpAppUi(tool, ui) {
283
+ const normalizedUi = {
284
+ ...ui,
285
+ visibility: normalizeMcpAppVisibility(ui.visibility)
286
+ };
287
+ return {
288
+ ...tool,
289
+ _meta: {
290
+ ...(tool._meta ?? {}),
291
+ ui: {
292
+ ...(tool._meta?.ui ?? {}),
293
+ ...normalizedUi
294
+ }
295
+ }
296
+ };
297
+ }
298
+ export function createMcpAppResource(input) {
299
+ const hasHtml = input.html !== undefined;
300
+ const hasBlob = input.blob !== undefined;
301
+ if (!hasHtml && !hasBlob && !input.read)
302
+ throw new Error(`MCP Apps resource ${input.uri} must provide html, blob, or read`);
303
+ const resource = {
304
+ uri: input.uri,
305
+ name: input.name,
306
+ description: input.description,
307
+ mimeType: mcpAppResourceMimeType,
308
+ scopes: input.scopes,
309
+ _meta: mcpAppResourceMeta(input._meta),
310
+ read: input.read
311
+ ? async (ctx) => normalizeMcpAppResourceRead(resource, await input.read(ctx))
312
+ : async () => normalizeMcpAppResourceRead(resource, {
313
+ uri: input.uri,
314
+ mimeType: mcpAppResourceMimeType,
315
+ ...(hasHtml ? { text: input.html } : { blob: input.blob }),
316
+ ...(input._meta ? { _meta: input._meta } : {})
317
+ })
318
+ };
319
+ validateMcpAppResource(resource);
320
+ return resource;
321
+ }
322
+ function hasScope(granted, required) {
323
+ return granted.includes(required);
324
+ }
325
+ function hasAnyScope(granted, required) {
326
+ return required.length === 0 || required.some((scope) => hasScope(granted, scope));
327
+ }
328
+ function requiredScopesForTool(tool) {
329
+ if (tool.scopes?.length)
330
+ return tool.scopes;
331
+ if (tool.annotations?.readOnlyHint)
332
+ return ["mcp:read", `mcp:tool:${tool.name}`];
333
+ return ["mcp:write", `mcp:tool:${tool.name}`];
334
+ }
335
+ function requiredScopesForResource(resource) {
336
+ return resource?.scopes?.length ? resource.scopes : ["mcp:read"];
337
+ }
338
+ function jsonResponse(body, init = {}) {
339
+ const headers = new Headers(init.headers);
340
+ if (!headers.has("content-type"))
341
+ headers.set("content-type", jsonHeaders["content-type"]);
342
+ return new Response(JSON.stringify(body), { ...init, headers });
343
+ }
344
+ function methodNotAllowed() {
345
+ return jsonResponse({ error: "Method not allowed" }, { status: 405, headers: { allow: "POST, OPTIONS" } });
346
+ }
347
+ function serverOrigin(request) {
348
+ const url = new URL(request.url);
349
+ return `${url.protocol}//${url.host}`;
350
+ }
351
+ function endpointPath(config) {
352
+ const raw = config.mcp?.endpoint || "/mcp";
353
+ return raw.startsWith("/") ? raw : `/${raw}`;
354
+ }
355
+ export function canonicalMcpResource(request, config) {
356
+ return `${serverOrigin(request)}${endpointPath(config)}`;
357
+ }
358
+ function normalizeEndpointPath(raw, fallback) {
359
+ const value = raw || fallback;
360
+ return value.startsWith("/") ? value : `/${value}`;
361
+ }
362
+ export function operatorMcpEndpoint(config) {
363
+ return normalizeEndpointPath(config.mcp?.operator?.endpoint, "/mcp/operator");
364
+ }
365
+ export function operatorMcpEnabled(config) {
366
+ return config.mcp?.enabled === true && config.mcp?.operator?.enabled !== false;
367
+ }
368
+ export function canonicalOperatorMcpResource(request, config) {
369
+ return `${serverOrigin(request)}${operatorMcpEndpoint(config)}`;
370
+ }
371
+ function canonicalMcpResourceForSurface(request, config, surface = "app") {
372
+ return surface === "operator" ? canonicalOperatorMcpResource(request, config) : canonicalMcpResource(request, config);
373
+ }
374
+ export const operatorMcpScopes = [
375
+ "operator:read",
376
+ "operator:health:read",
377
+ "operator:schema:read",
378
+ "operator:policy:read",
379
+ "operator:users:read",
380
+ "operator:orgs:read",
381
+ "operator:audit:read",
382
+ "operator:imports:read",
383
+ "operator:jobs:read",
384
+ "operator:retrieval:read",
385
+ "operator:search:read",
386
+ "operator:mcp-grants:read",
387
+ "operator:backups:read",
388
+ "operator:storage:read",
389
+ "operator:app-data:read",
390
+ "operator:app-data:write",
391
+ "operator:app-data:delete",
392
+ "operator:settings:write",
393
+ "operator:users:invite",
394
+ "operator:users:lifecycle",
395
+ "operator:findings:read",
396
+ "operator:feedback:read",
397
+ "operator:support:read",
398
+ "operator:agent:read",
399
+ "operator:agent-skills:read",
400
+ "operator:agent-skills:write",
401
+ "operator:agent-remote-mcp:read",
402
+ "operator:agent-remote-mcp:write",
403
+ "operator:ai:read",
404
+ "operator:fleet:read"
405
+ ];
406
+ /** The connector's name: `mcp.name`, else the app's name. */
407
+ export function mcpConnectorName(config) {
408
+ return config.mcp?.name?.trim() || config.appName || "Raybase";
409
+ }
410
+ const maxMcpInstructionsLength = 8192;
411
+ /** `mcp.instructions` as `initialize` returns it, or undefined when unset. */
412
+ export function mcpInstructions(config) {
413
+ const value = config.mcp?.instructions;
414
+ if (value === undefined || value === null)
415
+ return undefined;
416
+ if (typeof value !== "string")
417
+ throw new Error("mcp.instructions must be a string");
418
+ const trimmed = value.trim();
419
+ if (!trimmed)
420
+ return undefined;
421
+ if (trimmed.length > maxMcpInstructionsLength)
422
+ throw new Error(`mcp.instructions is ${trimmed.length} characters; the limit is ${maxMcpInstructionsLength}`);
423
+ return trimmed;
424
+ }
425
+ function operatorScopes(options = {}) {
426
+ return [...new Set([...operatorMcpScopes, ...(options.operatorPermissions ?? [])])];
427
+ }
428
+ function mcpSurfaces(request, config, options = {}) {
429
+ const surfaces = [
430
+ {
431
+ id: "app",
432
+ label: mcpConnectorName(config),
433
+ resource: canonicalMcpResource(request, config),
434
+ defaultScope: "mcp:read mcp:write",
435
+ scopesSupported: ["mcp:read", "mcp:write"]
436
+ }
437
+ ];
438
+ if (operatorMcpEnabled(config)) {
439
+ surfaces.push({
440
+ id: "operator",
441
+ label: "Operator MCP",
442
+ resource: canonicalOperatorMcpResource(request, config),
443
+ defaultScope: "operator:read",
444
+ scopesSupported: operatorScopes(options)
445
+ });
446
+ }
447
+ return surfaces;
448
+ }
449
+ function mcpSurfaceForResource(request, config, resource, options = {}) {
450
+ return mcpSurfaces(request, config, options).find((surface) => surface.resource === resource) ?? null;
451
+ }
452
+ function isOperatorScope(scope) {
453
+ return scope.startsWith("operator:");
454
+ }
455
+ function isCoreOperatorOnlyScope(scope) {
456
+ return scope === "operator:fleet:read" || /^operator:org:[^:]+:read$/.test(scope);
457
+ }
458
+ function canApproveOperatorMcp(user) {
459
+ return hasAdminAccess(user.operatorRole);
460
+ }
461
+ function oauthConfig(config) {
462
+ return {
463
+ enabled: config.mcp?.oauth?.enabled !== false,
464
+ dynamicClientRegistration: config.mcp?.oauth?.dynamicClientRegistration !== false,
465
+ clientCredentials: config.mcp?.oauth?.clientCredentials !== false,
466
+ allowedClientRedirectHosts: config.mcp?.oauth?.allowedClientRedirectHosts ?? [],
467
+ allowedClientMetadataHosts: config.mcp?.oauth?.allowedClientMetadataHosts ?? [],
468
+ accessTokenTtlSeconds: config.mcp?.oauth?.accessTokenTtlSeconds ?? defaultAccessTokenTtlSeconds,
469
+ refreshTokenTtlSeconds: config.mcp?.oauth?.refreshTokenTtlSeconds ?? defaultRefreshTokenTtlSeconds
470
+ };
471
+ }
472
+ function rateLimitKey(request, bucket) {
473
+ const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
474
+ const origin = request.headers.get("origin") || new URL(request.url).origin;
475
+ return `${bucket}:${ip}:${origin}`;
476
+ }
477
+ export function checkMcpRateLimit(request, bucket, limit = 60, windowMs = 60_000) {
478
+ const key = rateLimitKey(request, bucket);
479
+ const now = wallNowMs();
480
+ const current = rateLimitWindows.get(key);
481
+ if (!current || current.resetAt <= now) {
482
+ rateLimitWindows.set(key, { count: 1, resetAt: now + windowMs });
483
+ return null;
484
+ }
485
+ current.count += 1;
486
+ if (current.count <= limit)
487
+ return null;
488
+ return jsonResponse({ error: "Rate limit exceeded" }, { status: 429, headers: { "retry-after": String(Math.ceil((current.resetAt - now) / 1000)) } });
489
+ }
490
+ function corsHeaders(request, config) {
491
+ const headers = new Headers();
492
+ const origin = request.headers.get("origin");
493
+ if (origin && isAllowedOrigin(origin, request, config)) {
494
+ headers.set("access-control-allow-origin", origin);
495
+ headers.set("vary", "Origin");
496
+ headers.set("access-control-allow-methods", "POST, OPTIONS");
497
+ headers.set("access-control-allow-headers", "authorization, content-type, accept, mcp-protocol-version");
498
+ headers.set("access-control-max-age", "600");
499
+ }
500
+ return headers;
501
+ }
502
+ export function isAllowedOrigin(origin, request, config) {
503
+ let parsed;
504
+ try {
505
+ parsed = new URL(origin);
506
+ }
507
+ catch {
508
+ return false;
509
+ }
510
+ const current = new URL(request.url);
511
+ if (parsed.origin === current.origin)
512
+ return true;
513
+ const allowedHosts = oauthConfig(config).allowedClientRedirectHosts;
514
+ if (allowedHosts.includes("*"))
515
+ return true;
516
+ if (!allowedHosts.length) {
517
+ return config.mcp?.enabled === true && config.mcp?.oauth?.enabled !== false && config.mcp?.oauth?.dynamicClientRegistration !== false;
518
+ }
519
+ return hostnameAllowed(parsed.host, allowedHosts) || hostnameAllowed(parsed.hostname, allowedHosts);
520
+ }
521
+ function validateOrigin(request, config) {
522
+ const origin = request.headers.get("origin");
523
+ if (!origin || isAllowedOrigin(origin, request, config))
524
+ return null;
525
+ return jsonResponse({ error: "Origin is not allowed" }, { status: 403 });
526
+ }
527
+ function validateAccept(request) {
528
+ const accept = request.headers.get("accept");
529
+ if (!accept || accept.includes("*/*") || accept.includes("application/json") || accept.includes("text/event-stream"))
530
+ return null;
531
+ return jsonResponse({ error: "MCP endpoint requires Accept: application/json or text/event-stream" }, { status: 406 });
532
+ }
533
+ /**
534
+ * Which connector a metadata request is about, from the path after
535
+ * `/.well-known/oauth-protected-resource` or `/.well-known/oauth-authorization-server`:
536
+ * nothing or the app's endpoint is the app connector, the operator endpoint is
537
+ * the operator connector (when it is on), anything else is no connector.
538
+ */
539
+ export function mcpMetadataSurface(config, suffix) {
540
+ const path = suffix.replace(/\/+$/, "");
541
+ if (path === "" || path === endpointPath(config))
542
+ return "app";
543
+ if (operatorMcpEnabled(config) && path === operatorMcpEndpoint(config))
544
+ return "operator";
545
+ return null;
546
+ }
547
+ function surfaceScopes(config, options, surface) {
548
+ return surface === "operator" && operatorMcpEnabled(config) ? operatorScopes(options) : ["mcp:read", "mcp:write"];
549
+ }
550
+ /**
551
+ * RFC 9728 metadata for one connector. It announces that connector's scopes
552
+ * and nothing else, so a product that asks for every scope it is told about
553
+ * asks only for what the connector grants.
554
+ */
555
+ export function protectedResourceMetadata(request, config, options = {}, authorizationServer, surface = "app") {
556
+ const resource = canonicalMcpResourceForSurface(request, config, surface);
557
+ const issuer = authorizationServer ?? serverOrigin(request);
558
+ return {
559
+ resource,
560
+ resource_name: surface === "operator" ? `${mcpConnectorName(config)} (operator)` : mcpConnectorName(config),
561
+ authorization_servers: [issuer],
562
+ bearer_methods_supported: ["header"],
563
+ scopes_supported: surfaceScopes(config, options, surface),
564
+ resource_documentation: `${issuer}/docs/mcp`
565
+ };
566
+ }
567
+ /**
568
+ * RFC 8414 metadata. `scopes_supported` is the app connector's unless the
569
+ * request names the operator connector (`/.well-known/oauth-authorization-server/mcp/operator`).
570
+ */
571
+ export function authorizationServerMetadata(request, options = {}, authorizationServer, surface = "app", config) {
572
+ const issuer = authorizationServer ?? serverOrigin(request);
573
+ const scopeConfig = config ?? { appName: "Raybase", mcp: { enabled: true } };
574
+ return {
575
+ issuer,
576
+ authorization_endpoint: `${issuer}/api/auth/oauth/authorize`,
577
+ token_endpoint: `${issuer}/api/auth/oauth/token`,
578
+ registration_endpoint: `${issuer}/api/auth/oauth/register`,
579
+ revocation_endpoint: `${issuer}/api/auth/oauth/revoke`,
580
+ response_types_supported: ["code"],
581
+ grant_types_supported: ["authorization_code", "refresh_token", "client_credentials"],
582
+ code_challenge_methods_supported: ["S256"],
583
+ token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"],
584
+ scopes_supported: surfaceScopes(scopeConfig, options, surface),
585
+ // A client may name itself by the HTTPS address of its metadata document
586
+ // (OAuth Client ID Metadata Documents) instead of registering.
587
+ client_id_metadata_document_supported: true,
588
+ // RFC 9207: the redirect back from consent carries `iss`.
589
+ authorization_response_iss_parameter_supported: true
590
+ };
591
+ }
592
+ export function wwwAuthenticate(request, config, surface = "app") {
593
+ const resource = canonicalMcpResourceForSurface(request, config, surface);
594
+ const metadataPath = surface === "operator" ? `/.well-known/oauth-protected-resource${operatorMcpEndpoint(config)}` : "/.well-known/oauth-protected-resource";
595
+ const scope = surface === "operator" ? "operator:read" : "mcp:read mcp:write";
596
+ return `Bearer resource_metadata="${serverOrigin(request)}${metadataPath}", resource="${resource}", scope="${scope}"`;
597
+ }
598
+ function unauthorizedMcpResponse(request, config, error, surface = "app") {
599
+ return jsonResponse({ error }, {
600
+ status: 401,
601
+ headers: { "www-authenticate": wwwAuthenticate(request, config, surface), ...Object.fromEntries(corsHeaders(request, config)) }
602
+ });
603
+ }
604
+ async function sha256Base64url(value) {
605
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
606
+ return base64url(new Uint8Array(digest));
607
+ }
608
+ function randomToken(prefix) {
609
+ const bytes = new Uint8Array(32);
610
+ crypto.getRandomValues(bytes);
611
+ return `${prefix}_${base64url(bytes)}`;
612
+ }
613
+ function addSeconds(seconds) {
614
+ return new Date(wallNowMs() + seconds * 1000).toISOString();
615
+ }
616
+ function parseJson(value) {
617
+ if (typeof value !== "string" || !value)
618
+ return value;
619
+ try {
620
+ return JSON.parse(value);
621
+ }
622
+ catch {
623
+ return value;
624
+ }
625
+ }
626
+ function formOrJson(request) {
627
+ const contentType = request.headers.get("content-type") ?? "";
628
+ if (contentType.includes("application/json")) {
629
+ return request.json().then(asRecord).catch(() => ({}));
630
+ }
631
+ return request.formData().then((form) => {
632
+ const body = {};
633
+ for (const [key, value] of form.entries())
634
+ body[key] = typeof value === "string" ? value : value.name;
635
+ return body;
636
+ });
637
+ }
638
+ function parseRedirectUris(value) {
639
+ if (Array.isArray(value))
640
+ return value.map(String);
641
+ if (typeof value === "string") {
642
+ const parsed = parseJson(value);
643
+ if (Array.isArray(parsed))
644
+ return parsed.map(String);
645
+ return [value];
646
+ }
647
+ return [];
648
+ }
649
+ function hostnameAllowed(hostname, allowlist) {
650
+ if (!allowlist.length || allowlist.includes("*"))
651
+ return true;
652
+ return allowlist.includes(hostname);
653
+ }
654
+ function isLoopbackHost(hostname) {
655
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
656
+ }
657
+ function isPrivateIp(hostname) {
658
+ return (hostname === "0.0.0.0" ||
659
+ /^10\./.test(hostname) ||
660
+ /^127\./.test(hostname) ||
661
+ /^169\.254\./.test(hostname) ||
662
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
663
+ /^192\.168\./.test(hostname) ||
664
+ hostname === "::1" ||
665
+ hostname.toLowerCase().startsWith("fc") ||
666
+ hostname.toLowerCase().startsWith("fd") ||
667
+ hostname.toLowerCase().startsWith("fe80:"));
668
+ }
669
+ function isUrlLikeClientId(value) {
670
+ return /^https:\/\//i.test(value);
671
+ }
672
+ export function validateRedirectUri(uri, allowedHosts = []) {
673
+ let parsed;
674
+ try {
675
+ parsed = new URL(uri);
676
+ }
677
+ catch {
678
+ throw new Error("redirect_uri must be an absolute URL");
679
+ }
680
+ if (uri.includes("*"))
681
+ throw new Error("Wildcard redirect URIs are not allowed");
682
+ const loopback = isLoopbackHost(parsed.hostname);
683
+ if (parsed.protocol !== "https:" && !(loopback && parsed.protocol === "http:")) {
684
+ throw new Error("redirect_uri must use HTTPS except loopback localhost");
685
+ }
686
+ if (!hostnameAllowed(parsed.hostname, allowedHosts) && !hostnameAllowed(parsed.host, allowedHosts)) {
687
+ throw new Error("redirect_uri host is not allowed");
688
+ }
689
+ return parsed.toString();
690
+ }
691
+ export function validateClientMetadataUrl(raw, allowedHosts = []) {
692
+ const parsed = new URL(raw);
693
+ if (parsed.protocol !== "https:")
694
+ throw new Error("Client metadata URLs must use HTTPS");
695
+ if (parsed.username || parsed.password)
696
+ throw new Error("Client metadata URLs must not contain credentials");
697
+ // Workers do not expose DNS resolution results before fetch. Keep URL-form
698
+ // client metadata constrained with HTTPS, private literal host rejection,
699
+ // redirect re-validation, and production allowlists.
700
+ if (isLoopbackHost(parsed.hostname) || isPrivateIp(parsed.hostname))
701
+ throw new Error("Private client metadata URLs are not allowed");
702
+ if (!hostnameAllowed(parsed.hostname, allowedHosts) && !hostnameAllowed(parsed.host, allowedHosts)) {
703
+ throw new Error("client metadata host is not allowed");
704
+ }
705
+ return parsed.toString();
706
+ }
707
+ export async function fetchClientMetadataDocument(raw, options = {}) {
708
+ const allowedHosts = options.allowedHosts ?? [];
709
+ const fetcher = options.fetcher ?? fetch;
710
+ const timeoutMs = options.timeoutMs ?? 3000;
711
+ const maxBytes = options.maxBytes ?? 64 * 1024;
712
+ const maxRedirects = options.maxRedirects ?? 2;
713
+ let url = validateClientMetadataUrl(raw, allowedHosts);
714
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
715
+ const abort = new AbortController();
716
+ const timeout = setTimeout(() => abort.abort(), timeoutMs);
717
+ let response;
718
+ try {
719
+ response = await fetcher(url, {
720
+ method: "GET",
721
+ redirect: "manual",
722
+ headers: { accept: "application/json" },
723
+ signal: abort.signal
724
+ });
725
+ }
726
+ finally {
727
+ clearTimeout(timeout);
728
+ }
729
+ const location = response.headers.get("location");
730
+ if (response.status >= 300 && response.status < 400 && location) {
731
+ if (redirectCount >= maxRedirects)
732
+ throw new Error("Client metadata redirect limit exceeded");
733
+ url = validateClientMetadataUrl(new URL(location, url).toString(), allowedHosts);
734
+ continue;
735
+ }
736
+ if (!response.ok)
737
+ throw new Error(`Client metadata document returned ${response.status}`);
738
+ const length = response.headers.get("content-length");
739
+ if (length && Number(length) > maxBytes)
740
+ throw new Error("Client metadata document is too large");
741
+ const text = await limitedResponseText(response, maxBytes);
742
+ const parsed = asRecord(JSON.parse(text));
743
+ const clientId = asString(parsed.client_id);
744
+ const clientName = asString(parsed.client_name).trim();
745
+ const redirectUris = parseRedirectUris(parsed.redirect_uris);
746
+ if (clientId !== raw)
747
+ throw new Error("Client metadata client_id must exactly match the metadata URL");
748
+ if (!clientName)
749
+ throw new Error("Client metadata document requires client_name");
750
+ if (!redirectUris.length)
751
+ throw new Error("Client metadata document requires redirect_uris");
752
+ return { ...parsed, client_id: clientId, client_name: clientName, redirect_uris: redirectUris };
753
+ }
754
+ throw new Error("Client metadata document could not be fetched");
755
+ }
756
+ async function limitedResponseText(response, maxBytes) {
757
+ if (!response.body) {
758
+ const text = await response.text();
759
+ if (new TextEncoder().encode(text).byteLength > maxBytes)
760
+ throw new Error("Client metadata document is too large");
761
+ return text;
762
+ }
763
+ const reader = response.body.getReader();
764
+ const chunks = [];
765
+ let total = 0;
766
+ while (true) {
767
+ const { done, value } = await reader.read();
768
+ if (done)
769
+ break;
770
+ total += value.byteLength;
771
+ if (total > maxBytes) {
772
+ await reader.cancel().catch(() => undefined);
773
+ throw new Error("Client metadata document is too large");
774
+ }
775
+ chunks.push(value);
776
+ }
777
+ const bytes = new Uint8Array(total);
778
+ let offset = 0;
779
+ for (const chunk of chunks) {
780
+ bytes.set(chunk, offset);
781
+ offset += chunk.byteLength;
782
+ }
783
+ return new TextDecoder().decode(bytes);
784
+ }
785
+ const oauthClientColumns = `"clientId", "clientName", "redirectUris", "revokedAt", "clientSecretHash", "tokenEndpointAuthMethod", "grantTypes", "scopes", "resource", "grantId", "createdByUserId", "organizationId", "createdAt"`;
786
+ async function clientById(db, clientId) {
787
+ return db.first(`SELECT ${oauthClientColumns} FROM "_oauth_clients" WHERE "clientId" = ?`, [clientId]);
788
+ }
789
+ function clientGrantTypes(client) {
790
+ const parsed = parseJson(client.grantTypes ?? null);
791
+ return Array.isArray(parsed) ? parsed.map(String) : ["authorization_code", "refresh_token"];
792
+ }
793
+ function isConfidentialClient(client) {
794
+ return Boolean(client.clientSecretHash) && clientGrantTypes(client).includes("client_credentials");
795
+ }
796
+ function oauthClientSummary(client) {
797
+ return {
798
+ clientId: client.clientId,
799
+ clientName: client.clientName,
800
+ grantId: client.grantId ?? null,
801
+ grantTypes: clientGrantTypes(client),
802
+ tokenEndpointAuthMethod: client.tokenEndpointAuthMethod ?? "none",
803
+ scopes: splitScopes(parseJson(client.scopes ?? null)),
804
+ resource: client.resource ?? null,
805
+ organizationId: client.organizationId ?? null,
806
+ createdByUserId: client.createdByUserId ?? null,
807
+ createdAt: client.createdAt ?? "",
808
+ revokedAt: client.revokedAt,
809
+ lastUsedAt: client.lastUsedAt ?? null
810
+ };
811
+ }
812
+ function timingSafeEqualStrings(left, right) {
813
+ const a = new TextEncoder().encode(left);
814
+ const b = new TextEncoder().encode(right);
815
+ let diff = a.length ^ b.length;
816
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1)
817
+ diff |= (a[index] ?? 0) ^ (b[index] ?? 0);
818
+ return diff === 0;
819
+ }
820
+ /** RFC 6749 §2.3.1 client authentication: HTTP Basic first, then form/JSON parameters. */
821
+ function clientCredentialsFromRequest(request, body) {
822
+ const header = request.headers.get("authorization") ?? "";
823
+ const basic = /^Basic\s+(.+)$/i.exec(header);
824
+ if (basic) {
825
+ let decoded = "";
826
+ try {
827
+ decoded = atob(basic[1].trim());
828
+ }
829
+ catch {
830
+ return null;
831
+ }
832
+ const separator = decoded.indexOf(":");
833
+ if (separator === -1)
834
+ return null;
835
+ try {
836
+ return {
837
+ clientId: decodeURIComponent(decoded.slice(0, separator)),
838
+ clientSecret: decodeURIComponent(decoded.slice(separator + 1)),
839
+ method: "client_secret_basic"
840
+ };
841
+ }
842
+ catch {
843
+ return null;
844
+ }
845
+ }
846
+ const clientId = asString(body.client_id);
847
+ const clientSecret = asString(body.client_secret);
848
+ return clientId && clientSecret ? { clientId, clientSecret, method: "client_secret_post" } : null;
849
+ }
850
+ /**
851
+ * Registers a confidential OAuth client for the `client_credentials` grant.
852
+ * Called from `registerMcpOAuthClient` when the RFC 7591 body asks for that
853
+ * grant type; needs a signed-in user because the credential acts as that user
854
+ * (the grant's subject) with the scopes approved here, without a browser.
855
+ */
856
+ async function registerConfidentialClient(request, config, db, body, user, options, binding) {
857
+ const cfg = oauthConfig(config);
858
+ if (!cfg.clientCredentials)
859
+ return jsonResponse({ error: "invalid_client_metadata", error_description: "client_credentials clients are disabled" }, { status: 400 });
860
+ if (!user) {
861
+ return jsonResponse({ error: "invalid_client_metadata", error_description: "client_credentials clients must be registered by a signed-in user" }, { status: 401 });
862
+ }
863
+ const clientName = asString(body.client_name).trim();
864
+ if (!clientName)
865
+ return jsonResponse({ error: "invalid_client_metadata", error_description: "client_name is required" }, { status: 400 });
866
+ const requestedGrantTypes = Array.isArray(body.grant_types) ? body.grant_types.map(String) : [];
867
+ if (requestedGrantTypes.some((type) => type !== "client_credentials")) {
868
+ return jsonResponse({ error: "invalid_client_metadata", error_description: "A client_credentials client cannot also use browser grants" }, { status: 400 });
869
+ }
870
+ if (parseRedirectUris(body.redirect_uris).length) {
871
+ return jsonResponse({ error: "invalid_client_metadata", error_description: "client_credentials clients do not take redirect_uris" }, { status: 400 });
872
+ }
873
+ const authMethod = asString(body.token_endpoint_auth_method) || "client_secret_basic";
874
+ if (authMethod !== "client_secret_basic" && authMethod !== "client_secret_post") {
875
+ return jsonResponse({ error: "invalid_client_metadata", error_description: "token_endpoint_auth_method must be client_secret_basic or client_secret_post" }, { status: 400 });
876
+ }
877
+ const resource = asString(body.resource) || canonicalMcpResource(request, config);
878
+ let created;
879
+ try {
880
+ created = await createMcpMachineCredential(db, config, {
881
+ user,
882
+ clientName,
883
+ resource,
884
+ scope: asString(body.scope) || undefined,
885
+ organizationId: binding.organizationId ?? null,
886
+ tokenEndpointAuthMethod: authMethod,
887
+ metadata: { ...body, client_secret: undefined }
888
+ }, options);
889
+ }
890
+ catch (error) {
891
+ if (error instanceof McpMachineCredentialError)
892
+ return jsonResponse({ error: error.code, error_description: error.message }, { status: 400 });
893
+ throw error;
894
+ }
895
+ return jsonResponse({
896
+ client_id: created.clientId,
897
+ client_secret: created.clientSecret,
898
+ client_name: clientName,
899
+ grant_types: ["client_credentials"],
900
+ response_types: [],
901
+ token_endpoint_auth_method: authMethod,
902
+ scope: created.scopes.join(" "),
903
+ resource,
904
+ ...(created.organizationId ? { organization_id: created.organizationId } : {}),
905
+ grant_id: created.grantId,
906
+ client_id_issued_at: Math.floor(wallNowMs() / 1000),
907
+ client_secret_expires_at: 0
908
+ }, { status: 201 });
909
+ }
910
+ class McpMachineCredentialError extends Error {
911
+ code;
912
+ constructor(code, message) {
913
+ super(message);
914
+ this.code = code;
915
+ }
916
+ }
917
+ /**
918
+ * Makes a machine credential (a confidential `client_credentials` client and
919
+ * its grant) that acts as `user`, without a browser: for an app's own server
920
+ * code to hand a command-line tool or another service a way in. `resource` is
921
+ * the connector's absolute address (the app's `/mcp`, or `/mcp/operator`);
922
+ * `scope` defaults to that connector's default. The secret is returned once.
923
+ * Revoke with `revokeMcpOAuthClient`.
924
+ */
925
+ export async function createMcpMachineCredential(db, config, input, options = {}) {
926
+ await ensureMcpSystemTables(db);
927
+ if (!oauthConfig(config).clientCredentials)
928
+ throw new McpMachineCredentialError("invalid_client_metadata", "client_credentials clients are disabled");
929
+ const clientName = input.clientName.trim();
930
+ if (!clientName)
931
+ throw new McpMachineCredentialError("invalid_client_metadata", "clientName is required");
932
+ let origin;
933
+ try {
934
+ origin = new URL(input.resource).origin;
935
+ }
936
+ catch {
937
+ throw new McpMachineCredentialError("invalid_target", "resource must be the connector's absolute address");
938
+ }
939
+ const resource = input.resource;
940
+ const surface = mcpSurfaceForResource(new Request(origin), config, resource, options);
941
+ if (!surface)
942
+ throw new McpMachineCredentialError("invalid_target", "Unknown MCP resource");
943
+ const scopes = splitScopes(input.scope || surface.defaultScope);
944
+ const scopeError = authorizeMcpScopes(input.user, scopes, surface, options);
945
+ if (scopeError)
946
+ throw new McpMachineCredentialError("invalid_scope", scopeError);
947
+ const authMethod = input.tokenEndpointAuthMethod ?? "client_secret_basic";
948
+ const user = input.user;
949
+ const clientId = `raybase_cc_${randomId("client")}`;
950
+ const clientSecret = randomToken("rb_cs");
951
+ const grantId = randomId("mcp_grant");
952
+ const now = nowIso();
953
+ const subjectType = surface.id === "operator" ? "raybase-operator" : "raybase-user";
954
+ const organizationId = input.organizationId ?? null;
955
+ // Machine credentials carry no consent expiry of their own: they live until
956
+ // the client or the grant is revoked.
957
+ await db.run(`INSERT INTO "_mcp_grants" ("id", "subjectType", "subjectId", "userId", "hostAccount", "clientId", "clientName", "scopes", "resource", "createdAt", "expiresAt", "organizationId", "grantType")
958
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [grantId, subjectType, user.id, user.id, null, clientId, clientName, JSON.stringify(scopes), resource, now, null, organizationId, "client_credentials"]);
959
+ await db.run(`INSERT INTO "_oauth_clients" ("id", "clientId", "clientName", "redirectUris", "metadata", "createdAt", "clientSecretHash", "tokenEndpointAuthMethod", "grantTypes", "scopes", "resource", "grantId", "createdByUserId", "organizationId")
960
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
961
+ randomId("oauth_client"),
962
+ clientId,
963
+ clientName,
964
+ "[]",
965
+ JSON.stringify(input.metadata ?? { client_name: clientName, scope: scopes.join(" "), resource }),
966
+ now,
967
+ await sha256Hex(clientSecret),
968
+ authMethod,
969
+ JSON.stringify(["client_credentials"]),
970
+ JSON.stringify(scopes),
971
+ resource,
972
+ grantId,
973
+ user.id,
974
+ organizationId
975
+ ]);
976
+ await audit(db, user.id, surface.id === "operator" ? "operator_mcp.client.create" : "mcp.client.create", "_oauth_clients", clientId, {
977
+ grantId,
978
+ scopes,
979
+ resource,
980
+ surface: surface.id,
981
+ organizationId,
982
+ tokenEndpointAuthMethod: authMethod
983
+ });
984
+ await event(db, grantId, clientId, "grant.created", undefined, { grantType: "client_credentials" });
985
+ return { clientId, clientSecret, grantId, scopes, resource, organizationId };
986
+ }
987
+ export async function listMcpOAuthClientsForUser(db, userId) {
988
+ await ensureMcpSystemTables(db);
989
+ const rows = await db.all(`SELECT c."clientId", c."clientName", c."redirectUris", c."revokedAt", c."clientSecretHash", c."tokenEndpointAuthMethod", c."grantTypes", c."scopes", c."resource", c."grantId", c."createdByUserId", c."organizationId", c."createdAt", g."lastUsedAt"
990
+ FROM "_oauth_clients" c
991
+ LEFT JOIN "_mcp_grants" g ON g."id" = c."grantId"
992
+ WHERE c."createdByUserId" = ?
993
+ ORDER BY c."createdAt" DESC LIMIT 200`, [userId]);
994
+ return rows.map(oauthClientSummary);
995
+ }
996
+ /**
997
+ * Revokes a confidential client and its grant family. When `ownerUserId` is
998
+ * given only that user's clients are revoked; admins pass null.
999
+ */
1000
+ export async function revokeMcpOAuthClient(db, clientId, actor) {
1001
+ await ensureMcpSystemTables(db);
1002
+ const client = await clientById(db, clientId);
1003
+ if (!client || !client.clientSecretHash)
1004
+ return false;
1005
+ if (actor.ownerUserId && client.createdByUserId !== actor.ownerUserId)
1006
+ return false;
1007
+ const now = nowIso();
1008
+ await db.run(`UPDATE "_oauth_clients" SET "revokedAt" = ? WHERE "clientId" = ? AND "revokedAt" IS NULL`, [now, clientId]);
1009
+ if (client.grantId)
1010
+ await revokeGrantFamily(db, client.grantId, "client_revoked");
1011
+ await audit(db, actor.userId, "mcp.client.revoke", "_oauth_clients", clientId, { grantId: client.grantId ?? null });
1012
+ return true;
1013
+ }
1014
+ async function resolveOAuthClient(config, db, clientId) {
1015
+ const stored = await clientById(db, clientId);
1016
+ if (stored || !isUrlLikeClientId(clientId))
1017
+ return stored;
1018
+ const cfg = oauthConfig(config);
1019
+ const metadata = await fetchClientMetadataDocument(clientId, { allowedHosts: cfg.allowedClientMetadataHosts });
1020
+ const redirectUris = metadata.redirect_uris.map((uri) => validateRedirectUri(uri, cfg.allowedClientRedirectHosts));
1021
+ const now = nowIso();
1022
+ await db.run(`INSERT OR IGNORE INTO "_oauth_clients" ("id", "clientId", "clientName", "redirectUris", "metadata", "createdAt")
1023
+ VALUES (?, ?, ?, ?, ?, ?)`, [randomId("oauth_client"), metadata.client_id, metadata.client_name, JSON.stringify(redirectUris), JSON.stringify(metadata), now]);
1024
+ return (await clientById(db, clientId)) ?? {
1025
+ clientId: metadata.client_id,
1026
+ clientName: metadata.client_name,
1027
+ redirectUris: JSON.stringify(redirectUris),
1028
+ revokedAt: null
1029
+ };
1030
+ }
1031
+ /**
1032
+ * RFC 7591 registration. Public PKCE clients register anonymously as before.
1033
+ * A body whose `grant_types` is `["client_credentials"]` registers a
1034
+ * confidential client instead: it needs the signed-in `user`, takes `scope`
1035
+ * (and optionally `resource`), and answers with a one-time `client_secret`.
1036
+ */
1037
+ export async function registerMcpOAuthClient(request, config, db, user = null, options = {}, binding = {}) {
1038
+ const limited = checkMcpRateLimit(request, "oauth-register", 20);
1039
+ if (limited)
1040
+ return limited;
1041
+ const cfg = oauthConfig(config);
1042
+ if (!cfg.enabled)
1043
+ return jsonResponse({ error: "OAuth is disabled" }, { status: 404 });
1044
+ const body = await request.json().then(asRecord).catch(() => ({}));
1045
+ const requestedGrantTypes = Array.isArray(body.grant_types) ? body.grant_types.map(String) : [];
1046
+ if (requestedGrantTypes.includes("client_credentials")) {
1047
+ return registerConfidentialClient(request, config, db, body, user, options, binding);
1048
+ }
1049
+ if (!cfg.dynamicClientRegistration)
1050
+ return jsonResponse({ error: "Dynamic client registration is disabled" }, { status: 404 });
1051
+ if (requestedGrantTypes.some((type) => type !== "authorization_code" && type !== "refresh_token")) {
1052
+ return jsonResponse({ error: "invalid_client_metadata", error_description: "Unsupported grant_types" }, { status: 400 });
1053
+ }
1054
+ const clientName = asString(body.client_name).trim();
1055
+ if (!clientName)
1056
+ return jsonResponse({ error: "client_name is required" }, { status: 400 });
1057
+ const redirectUris = parseRedirectUris(body.redirect_uris);
1058
+ if (!redirectUris.length)
1059
+ return jsonResponse({ error: "redirect_uris is required" }, { status: 400 });
1060
+ let normalizedRedirects;
1061
+ try {
1062
+ normalizedRedirects = redirectUris.map((uri) => validateRedirectUri(uri, cfg.allowedClientRedirectHosts));
1063
+ const metadataUrl = asString(body.client_uri || body.logo_uri || body.tos_uri || body.policy_uri);
1064
+ if (metadataUrl)
1065
+ validateClientMetadataUrl(metadataUrl, cfg.allowedClientMetadataHosts);
1066
+ }
1067
+ catch (error) {
1068
+ return jsonResponse({ error: error instanceof Error ? error.message : "Invalid client metadata" }, { status: 400 });
1069
+ }
1070
+ const clientId = `raybase_dcr_${randomId("client")}`;
1071
+ const now = nowIso();
1072
+ await db.run(`INSERT INTO "_oauth_clients" ("id", "clientId", "clientName", "redirectUris", "metadata", "createdAt")
1073
+ VALUES (?, ?, ?, ?, ?, ?)`, [randomId("oauth_client"), clientId, clientName, JSON.stringify(normalizedRedirects), JSON.stringify(body), now]);
1074
+ return jsonResponse({
1075
+ client_id: clientId,
1076
+ client_name: clientName,
1077
+ redirect_uris: normalizedRedirects,
1078
+ grant_types: ["authorization_code", "refresh_token"],
1079
+ response_types: ["code"],
1080
+ token_endpoint_auth_method: "none",
1081
+ client_id_issued_at: Math.floor(wallNowMs() / 1000)
1082
+ }, { status: 201 });
1083
+ }
1084
+ function redirectWithError(redirectUri, state, error) {
1085
+ const url = new URL(redirectUri);
1086
+ url.searchParams.set("error", error);
1087
+ if (state)
1088
+ url.searchParams.set("state", state);
1089
+ return Response.redirect(url.toString(), 302);
1090
+ }
1091
+ function operatorConsentCopy(config, scopeValue, surface) {
1092
+ if (surface.id !== "operator") {
1093
+ return `${config.appName} will allow this AI client to use the requested MCP tools with your account permissions.`;
1094
+ }
1095
+ const scopes = splitScopes(scopeValue || surface.defaultScope);
1096
+ const writeCapabilities = [
1097
+ scopes.includes("operator:app-data:write") ? "create and update app-defined data rows" : null,
1098
+ scopes.includes("operator:app-data:delete") ? "delete app-defined data rows" : null,
1099
+ scopes.includes("operator:settings:write") ? "set and reset app-scoped settings" : null,
1100
+ scopes.includes("operator:users:invite") ? "create normal user invites" : null,
1101
+ scopes.includes("operator:users:lifecycle") ? "deactivate and reactivate users, and revoke user sessions" : null
1102
+ ].filter(Boolean);
1103
+ if (!writeCapabilities.length) {
1104
+ return `${config.appName} will allow this AI client to inspect read-only production evidence through Operator MCP. It cannot mutate schema, data, users, jobs, deployments, or code.`;
1105
+ }
1106
+ return `${config.appName} will allow this AI client to inspect Operator MCP evidence and perform these audited operator actions: ${writeCapabilities.join("; ")}. Operator MCP still cannot run raw SQL, mutate schema, edit code, deploy, restore, destroy the app, mutate secrets or bindings, escalate operator roles, or perform broad unbounded exports.`;
1107
+ }
1108
+ function consentChoice(consent) {
1109
+ const choice = consent?.choice;
1110
+ if (!choice || !choice.name || !Array.isArray(choice.options) || choice.options.length < 2)
1111
+ return null;
1112
+ return choice;
1113
+ }
1114
+ function consentStylesheetHref(config) {
1115
+ const href = config.mcp?.consent?.stylesheet?.trim();
1116
+ if (!href)
1117
+ return null;
1118
+ if (href.startsWith("/") && !href.startsWith("//"))
1119
+ return href;
1120
+ try {
1121
+ if (new URL(href).protocol === "https:")
1122
+ return href;
1123
+ }
1124
+ catch {
1125
+ // Fall through to the refusal below.
1126
+ }
1127
+ throw new Error("mcp.consent.stylesheet must be a root-relative path or an HTTPS URL");
1128
+ }
1129
+ /** What a consent page shows, and the fields it posts back. */
1130
+ function consentView(config, validated, consent) {
1131
+ const choice = consentChoice(consent);
1132
+ const fields = Object.fromEntries(Object.entries(validated.values).filter(([key]) => !choice || key !== choice.name));
1133
+ const scopes = splitScopes(validated.values.scope || validated.surface.defaultScope);
1134
+ return {
1135
+ client: { name: validated.clientName },
1136
+ connector: { name: validated.surface.label, kind: validated.surface.id },
1137
+ description: operatorConsentCopy(config, validated.values.scope, validated.surface),
1138
+ scopes,
1139
+ choice: choice
1140
+ ? { ...choice, selected: choice.selected && choice.options.some((option) => option.value === choice.selected) ? choice.selected : choice.options[0].value }
1141
+ : null,
1142
+ action: "/api/auth/oauth/authorize",
1143
+ method: "POST",
1144
+ fields
1145
+ };
1146
+ }
1147
+ function consentPage(config, view) {
1148
+ const hidden = Object.entries(view.fields)
1149
+ .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}">`)
1150
+ .join("");
1151
+ const stylesheet = consentStylesheetHref(config);
1152
+ const css = config.mcp?.consent?.css ?? "";
1153
+ const choice = view.choice
1154
+ ? `<label class="rb-consent-choice">${escapeHtml(view.choice.label)}
1155
+ <select name="${escapeHtml(view.choice.name)}">${view.choice.options
1156
+ .map((option) => `<option value="${escapeHtml(option.value)}"${option.value === view.choice.selected ? " selected" : ""}>${escapeHtml(option.label)}</option>`)
1157
+ .join("")}</select></label>`
1158
+ : "";
1159
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1160
+ <title>Connect ${escapeHtml(view.client.name)} to ${escapeHtml(view.connector.name)}</title>
1161
+ <style>
1162
+ :root { color-scheme: light dark; --rb-consent-bg: Canvas; --rb-consent-fg: CanvasText; --rb-consent-muted: color-mix(in srgb, CanvasText 65%, Canvas); --rb-consent-border: color-mix(in srgb, CanvasText 18%, Canvas); --rb-consent-accent: #2457d6; --rb-consent-accent-fg: #fff; --rb-consent-radius: 10px; --rb-consent-font: system-ui, -apple-system, "Segoe UI", sans-serif; }
1163
+ body { margin: 0; background: var(--rb-consent-bg); color: var(--rb-consent-fg); font-family: var(--rb-consent-font); line-height: 1.5; }
1164
+ .rb-consent { max-width: 480px; margin: 12vh auto 48px; padding: 0 20px; }
1165
+ .rb-consent h1 { font-size: 1.4rem; line-height: 1.25; margin: 0 0 12px; }
1166
+ .rb-consent p { margin: 0 0 16px; }
1167
+ .rb-consent-scopes { color: var(--rb-consent-muted); font-size: 0.9rem; }
1168
+ .rb-consent-choice { display: grid; gap: 6px; margin: 0 0 20px; font-weight: 600; }
1169
+ .rb-consent-choice select { font: inherit; font-weight: 400; padding: 8px 10px; border: 1px solid var(--rb-consent-border); border-radius: var(--rb-consent-radius); background: var(--rb-consent-bg); color: var(--rb-consent-fg); }
1170
+ .rb-consent-actions { display: flex; gap: 10px; }
1171
+ .rb-consent button { font: inherit; padding: 9px 18px; border-radius: var(--rb-consent-radius); border: 1px solid var(--rb-consent-border); background: transparent; color: var(--rb-consent-fg); cursor: pointer; }
1172
+ .rb-consent button[value="approve"] { background: var(--rb-consent-accent); border-color: var(--rb-consent-accent); color: var(--rb-consent-accent-fg); }
1173
+ </style>${stylesheet ? `\n <link rel="stylesheet" href="${escapeHtml(stylesheet)}">` : ""}${css ? `\n <style>${css.replace(/<\/style/gi, "<\\/style")}</style>` : ""}
1174
+ </head><body>
1175
+ <main class="rb-consent">
1176
+ <h1>Connect ${escapeHtml(view.client.name)} to ${escapeHtml(view.connector.name)}</h1>
1177
+ <p>${escapeHtml(view.description)}</p>
1178
+ <p class="rb-consent-scopes">Permissions: ${escapeHtml(view.scopes.join(" "))}</p>
1179
+ <form method="post" action="${escapeHtml(view.action)}">${hidden}${choice}
1180
+ <div class="rb-consent-actions"><button name="decision" value="approve">Approve</button>
1181
+ <button name="decision" value="deny">Deny</button></div>
1182
+ </form>
1183
+ </main>
1184
+ </body></html>`;
1185
+ }
1186
+ function escapeHtml(value) {
1187
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char] ?? char);
1188
+ }
1189
+ async function authorizationInput(request) {
1190
+ const url = new URL(request.url);
1191
+ return request.method === "POST"
1192
+ ? Object.fromEntries(Object.entries(await formOrJson(request)).map(([key, value]) => [key, asString(value)]))
1193
+ : Object.fromEntries(url.searchParams.entries());
1194
+ }
1195
+ async function validateAuthorizationInput(request, config, db, input, options = {}) {
1196
+ const state = input.state || null;
1197
+ if (input.response_type !== "code")
1198
+ return jsonResponse({ error: "Only response_type=code is supported" }, { status: 400 });
1199
+ if (!input.client_id)
1200
+ return jsonResponse({ error: "client_id is required" }, { status: 400 });
1201
+ if (!input.redirect_uri)
1202
+ return jsonResponse({ error: "redirect_uri is required" }, { status: 400 });
1203
+ let client;
1204
+ try {
1205
+ client = await resolveOAuthClient(config, db, input.client_id);
1206
+ }
1207
+ catch (error) {
1208
+ return jsonResponse({ error: error instanceof Error ? error.message : "Invalid client metadata document" }, { status: 400 });
1209
+ }
1210
+ if (!client || client.revokedAt)
1211
+ return jsonResponse({ error: "Unknown or revoked OAuth client" }, { status: 400 });
1212
+ const redirects = parseRedirectUris(client.redirectUris);
1213
+ let redirectUri;
1214
+ try {
1215
+ redirectUri = validateRedirectUri(input.redirect_uri, oauthConfig(config).allowedClientRedirectHosts);
1216
+ }
1217
+ catch {
1218
+ return jsonResponse({ error: "Invalid redirect_uri" }, { status: 400 });
1219
+ }
1220
+ if (!redirects.includes(redirectUri))
1221
+ return jsonResponse({ error: "redirect_uri is not registered for this client" }, { status: 400 });
1222
+ if (!input.code_challenge || input.code_challenge_method !== "S256") {
1223
+ return redirectWithError(redirectUri, state, "invalid_request");
1224
+ }
1225
+ const resource = input.resource || canonicalMcpResource(request, config);
1226
+ const surface = mcpSurfaceForResource(request, config, resource, options);
1227
+ if (!surface)
1228
+ return redirectWithError(redirectUri, state, "invalid_target");
1229
+ return {
1230
+ clientName: client.clientName,
1231
+ values: {
1232
+ // The consent form posts every value back and the POST is validated
1233
+ // like the GET, so the two fixed ones travel too.
1234
+ response_type: "code",
1235
+ code_challenge_method: "S256",
1236
+ client_id: input.client_id,
1237
+ redirect_uri: redirectUri,
1238
+ scope: input.scope || surface.defaultScope,
1239
+ resource,
1240
+ state: input.state || "",
1241
+ code_challenge: input.code_challenge,
1242
+ // Carried through the consent form so the POST can re-resolve it.
1243
+ ...(input.org ? { org: input.org } : {})
1244
+ },
1245
+ surface
1246
+ };
1247
+ }
1248
+ /**
1249
+ * The `org` hint an authorization request carries: the query string on GET,
1250
+ * the form or JSON body on POST. Consumes the body, so callers that go on to
1251
+ * call `authorizeMcpOAuthRequest` pass `request.clone()`. Core records
1252
+ * nothing for it; a layered runtime resolves it to an organization the
1253
+ * signed-in user belongs to and passes that as the grant binding.
1254
+ */
1255
+ export async function mcpAuthorizationOrganizationHint(request) {
1256
+ const input = await authorizationInput(request);
1257
+ const value = asString(input.org).trim();
1258
+ return value || null;
1259
+ }
1260
+ /** The identity a host-account grant acts as: a plain app user, never an operator. */
1261
+ function hostAccountUser(hostAccount, subject) {
1262
+ return {
1263
+ id: subject.id,
1264
+ email: subject.email,
1265
+ name: hostAccount,
1266
+ operatorRole: null,
1267
+ appRoles: ["user"]
1268
+ };
1269
+ }
1270
+ function resolveHostSubject(hostApproval, hostAccount) {
1271
+ const subject = hostApproval.subject(hostAccount);
1272
+ if (!subject?.type || !subject.id)
1273
+ throw new Error("Host approval subject needs a type and an id");
1274
+ // Core's own subject types carry meaning (operator grants); a host subject may not claim them.
1275
+ if (subject.type.startsWith("raybase-"))
1276
+ throw new Error("Host approval subject type may not start with raybase-");
1277
+ return subject;
1278
+ }
1279
+ /** The user a stored grant acts as, or null when it no longer resolves. */
1280
+ async function grantUser(db, grant, userId, options) {
1281
+ if (!grant.hostAccount)
1282
+ return (await (options.resolveOAuthUser?.(db, userId) ?? loadUser(db, userId))) ?? null;
1283
+ // A host-account grant resolves only while host approval is configured, and
1284
+ // only to the subject it was made for.
1285
+ if (!options.hostApproval)
1286
+ return null;
1287
+ let subject;
1288
+ try {
1289
+ subject = resolveHostSubject(options.hostApproval, grant.hostAccount);
1290
+ }
1291
+ catch {
1292
+ return null;
1293
+ }
1294
+ if (subject.type !== grant.subjectType || subject.id !== grant.subjectId)
1295
+ return null;
1296
+ return hostAccountUser(grant.hostAccount, subject);
1297
+ }
1298
+ async function createGrantAndCode(db, config, request, user, values, clientName, options = {}, binding = {}, host) {
1299
+ const resource = values.resource || canonicalMcpResource(request, config);
1300
+ const surface = mcpSurfaceForResource(request, config, resource, options);
1301
+ if (!surface)
1302
+ return redirectWithError(values.redirect_uri, values.state || null, "invalid_target");
1303
+ const hostAccount = host?.account ?? null;
1304
+ const scopes = splitScopes(values.scope || "mcp:read mcp:write");
1305
+ const scopeError = authorizeMcpScopes(user, scopes, surface, options);
1306
+ if (scopeError)
1307
+ return redirectWithError(values.redirect_uri, values.state || null, "invalid_scope");
1308
+ const grantId = randomId("mcp_grant");
1309
+ const now = nowIso();
1310
+ const subjectType = host ? host.subject.type : surface.id === "operator" ? "raybase-operator" : "raybase-user";
1311
+ const subjectId = host ? host.subject.id : user.id;
1312
+ const organizationId = host ? null : binding.organizationId ?? null;
1313
+ await db.run(`INSERT INTO "_mcp_grants" ("id", "subjectType", "subjectId", "userId", "hostAccount", "clientId", "clientName", "scopes", "resource", "createdAt", "expiresAt", "organizationId", "grantType")
1314
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [grantId, subjectType, subjectId, user.id, hostAccount, values.client_id, clientName, JSON.stringify(scopes), resource, now, addSeconds(defaultGrantTtlSeconds), organizationId, "authorization_code"]);
1315
+ const code = randomToken("rb_code");
1316
+ await db.run(`INSERT INTO "_oauth_authorization_codes"
1317
+ ("id", "codeHash", "clientId", "redirectUri", "scope", "resource", "codeChallenge", "codeChallengeMethod", "grantId", "userId", "hostAccount", "state", "expiresAt", "createdAt")
1318
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1319
+ randomId("oauth_code"),
1320
+ await sha256Hex(code),
1321
+ values.client_id,
1322
+ values.redirect_uri,
1323
+ JSON.stringify(scopes),
1324
+ resource,
1325
+ values.code_challenge,
1326
+ "S256",
1327
+ grantId,
1328
+ user.id,
1329
+ hostAccount,
1330
+ values.state || null,
1331
+ addSeconds(300),
1332
+ now
1333
+ ]);
1334
+ await audit(db, host ? null : user.id, surface.id === "operator" ? "operator_mcp.grant.create" : "mcp.grant.create", "_mcp_grants", grantId, {
1335
+ clientId: values.client_id,
1336
+ scopes,
1337
+ resource,
1338
+ surface: surface.id,
1339
+ hostAccount,
1340
+ organizationId
1341
+ });
1342
+ const redirect = new URL(values.redirect_uri);
1343
+ redirect.searchParams.set("code", code);
1344
+ if (values.state)
1345
+ redirect.searchParams.set("state", values.state);
1346
+ // RFC 9207: name the issuer, so a client talking to several can tell which answered.
1347
+ redirect.searchParams.set("iss", serverOrigin(request));
1348
+ return Response.redirect(redirect.toString(), 302);
1349
+ }
1350
+ function authorizeMcpScopes(user, scopes, surface, options = {}) {
1351
+ if (surface.id === "operator") {
1352
+ if (!canApproveOperatorMcp(user))
1353
+ return "Operator role owner or admin is required";
1354
+ if (!scopes.length || scopes.some((scope) => !isOperatorScope(scope)))
1355
+ return "Operator MCP grants may only request operator scopes";
1356
+ const unsupported = scopes.find((scope) => !surface.scopesSupported.includes(scope)
1357
+ && !(options.allowExtendedOperatorScopes && /^operator:org:[^:]+:(?:read|write)$/.test(scope)));
1358
+ if (unsupported)
1359
+ return `Unsupported Operator MCP scope: ${unsupported}`;
1360
+ if (!options.allowExtendedOperatorScopes && scopes.some(isCoreOperatorOnlyScope)) {
1361
+ return "Core Operator MCP does not support fleet or organization scopes";
1362
+ }
1363
+ if (options.allowExtendedOperatorScopes) {
1364
+ if (scopes.includes("operator:fleet:read") && user.operatorRole !== "owner") {
1365
+ return "Owner role is required for fleet scope";
1366
+ }
1367
+ const requestsOrganizationWrite = scopes.some((scope) => [
1368
+ "operator:app-data:write",
1369
+ "operator:app-data:delete",
1370
+ "operator:settings:write",
1371
+ "operator:users:invite",
1372
+ "operator:users:lifecycle"
1373
+ ].includes(scope));
1374
+ if (requestsOrganizationWrite && !scopes.some((scope) => /^operator:org:[^:]+:write$/.test(scope))) {
1375
+ return "Organization write scope is required for mutating Operator MCP permissions";
1376
+ }
1377
+ }
1378
+ return null;
1379
+ }
1380
+ if (scopes.some(isOperatorScope))
1381
+ return "App MCP grants cannot request operator scopes";
1382
+ return null;
1383
+ }
1384
+ function mcpSignInPath(config) {
1385
+ const raw = config.mcp?.oauth?.signInPath?.trim() || "/login";
1386
+ if (!raw.startsWith("/") || raw.startsWith("//"))
1387
+ throw new Error("mcp.oauth.signInPath must be a root-relative path");
1388
+ return raw;
1389
+ }
1390
+ /** The authorization request as a GET address on this origin, path and query only. */
1391
+ function authorizationRequestPath(values, choice, input) {
1392
+ const query = new URLSearchParams();
1393
+ for (const [key, value] of Object.entries(values))
1394
+ if (value)
1395
+ query.set(key, value);
1396
+ if (choice && input[choice.name])
1397
+ query.set(choice.name, input[choice.name]);
1398
+ return `/api/auth/oauth/authorize?${query.toString()}`;
1399
+ }
1400
+ /** Sends a person with no session to sign in, with the whole request kept as `next`. */
1401
+ function signInRedirect(request, config, values, input, consent) {
1402
+ const signIn = new URL(mcpSignInPath(config), request.url);
1403
+ signIn.searchParams.set("next", authorizationRequestPath(values, consentChoice(consent), input));
1404
+ return new Response(null, { status: 302, headers: { location: signIn.toString(), "cache-control": "no-store" } });
1405
+ }
1406
+ export async function authorizeMcpOAuthRequest(request, config, db, user, options = {}, binding = {}, consent = {}) {
1407
+ const limited = checkMcpRateLimit(request, "oauth-authorize", 120);
1408
+ if (limited)
1409
+ return limited;
1410
+ if (!oauthConfig(config).enabled)
1411
+ return jsonResponse({ error: "OAuth is disabled" }, { status: 404 });
1412
+ const input = await authorizationInput(request);
1413
+ const validated = await validateAuthorizationInput(request, config, db, input, options);
1414
+ if (validated instanceof Response)
1415
+ return validated;
1416
+ if (!user)
1417
+ return signInRedirect(request, config, validated.values, input, consent);
1418
+ const scopeError = authorizeMcpScopes(user, splitScopes(validated.values.scope), validated.surface, options);
1419
+ if (scopeError)
1420
+ return redirectWithError(validated.values.redirect_uri, validated.values.state || null, "invalid_scope");
1421
+ if (request.method === "POST") {
1422
+ if (input.decision !== "approve")
1423
+ return redirectWithError(validated.values.redirect_uri, validated.values.state || null, "access_denied");
1424
+ return createGrantAndCode(db, config, request, user, validated.values, validated.clientName, options, binding);
1425
+ }
1426
+ const ownPage = config.mcp?.consent?.path?.trim();
1427
+ if (ownPage) {
1428
+ if (!ownPage.startsWith("/") || ownPage.startsWith("//"))
1429
+ throw new Error("mcp.consent.path must be a root-relative path");
1430
+ const target = new URL(ownPage, request.url);
1431
+ target.search = new URL(request.url).search;
1432
+ return new Response(null, { status: 302, headers: { location: target.toString(), "cache-control": "no-store" } });
1433
+ }
1434
+ return new Response(consentPage(config, consentView(config, validated, consent)), {
1435
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-frame-options": "DENY", "content-security-policy": "frame-ancestors 'none'" }
1436
+ });
1437
+ }
1438
+ /**
1439
+ * `GET /api/auth/oauth/consent`: what an app's own consent page (`mcp.consent.path`)
1440
+ * shows, for the authorization request in the query. Answers 401 with
1441
+ * `signIn` when nobody is signed in.
1442
+ */
1443
+ export async function mcpOAuthConsentContext(request, config, db, user, options = {}, consent = {}) {
1444
+ const limited = checkMcpRateLimit(request, "oauth-authorize", 120);
1445
+ if (limited)
1446
+ return limited;
1447
+ if (!oauthConfig(config).enabled)
1448
+ return jsonResponse({ error: "OAuth is disabled" }, { status: 404 });
1449
+ const input = Object.fromEntries(new URL(request.url).searchParams.entries());
1450
+ const validated = await validateAuthorizationInput(request, config, db, input, options);
1451
+ if (validated instanceof Response) {
1452
+ if (validated.status === 302)
1453
+ return jsonResponse({ error: "invalid_request", redirect: validated.headers.get("location") }, { status: 400 });
1454
+ return validated;
1455
+ }
1456
+ if (!user) {
1457
+ const signIn = signInRedirect(request, config, validated.values, input, consent).headers.get("location");
1458
+ return jsonResponse({ error: "Authentication required", signIn }, { status: 401, headers: { "cache-control": "no-store" } });
1459
+ }
1460
+ const scopeError = authorizeMcpScopes(user, splitScopes(validated.values.scope), validated.surface, options);
1461
+ if (scopeError)
1462
+ return jsonResponse({ error: "invalid_scope", error_description: scopeError }, { status: 403 });
1463
+ return jsonResponse(consentView(config, validated, consent), { headers: { "cache-control": "no-store" } });
1464
+ }
1465
+ function mcpHostApprovalPath(options, key) {
1466
+ const defaults = { adminAuthPath: "/auth-mcp", connectPath: "/mcp/connect", oauthCompletePath: "/mcp/oauth/complete" };
1467
+ const raw = options[key] ?? defaults[key];
1468
+ return raw.startsWith("/") ? raw : `/${raw}`;
1469
+ }
1470
+ function hostAccountParam(options) {
1471
+ return options.hostAccountParam || "host_account";
1472
+ }
1473
+ function connectPage(request, config, options) {
1474
+ const url = new URL(request.url);
1475
+ const param = hostAccountParam(options);
1476
+ const copy = {
1477
+ title: "Connect an account",
1478
+ description: "Enter the account to connect, then continue to approve the connection there.",
1479
+ label: "Account",
1480
+ placeholder: "",
1481
+ ...options.connectPage
1482
+ };
1483
+ const hidden = [...url.searchParams.entries()]
1484
+ .filter(([key]) => key !== param)
1485
+ .map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}">`)
1486
+ .join("");
1487
+ const placeholder = copy.placeholder ? ` placeholder="${escapeHtml(copy.placeholder)}"` : "";
1488
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Connect ${escapeHtml(config.appName)}</title></head><body>
1489
+ <main style="font-family: system-ui, sans-serif; max-width: 560px; margin: 48px auto; line-height: 1.5;">
1490
+ <h1>${escapeHtml(copy.title)}</h1>
1491
+ <p>${escapeHtml(copy.description)}</p>
1492
+ <form method="post">
1493
+ ${hidden}
1494
+ <label>${escapeHtml(copy.label)} <input name="${escapeHtml(param)}"${placeholder} required></label>
1495
+ <button type="submit">Continue</button>
1496
+ </form>
1497
+ </main>
1498
+ </body></html>`;
1499
+ }
1500
+ /** The page that asks which host account to connect (GET), and its submit (POST) back into authorization. */
1501
+ export async function handleMcpHostConnect(request, config, options) {
1502
+ const hostOptions = options;
1503
+ if (request.method === "GET")
1504
+ return new Response(connectPage(request, config, hostOptions), { headers: { "content-type": "text/html; charset=utf-8" } });
1505
+ if (request.method !== "POST")
1506
+ return methodNotAllowed();
1507
+ const param = hostAccountParam(options);
1508
+ const body = await formOrJson(request);
1509
+ const hostAccount = options.normalizeHostAccount(asString(body[param]));
1510
+ const url = new URL("/api/auth/oauth/authorize", request.url);
1511
+ for (const [key, value] of Object.entries(body)) {
1512
+ if (key !== param)
1513
+ url.searchParams.set(key, asString(value));
1514
+ }
1515
+ url.searchParams.set(param, hostAccount);
1516
+ return Response.redirect(url.toString(), 302);
1517
+ }
1518
+ /**
1519
+ * The authorization endpoint when host approval is on: records a pending
1520
+ * authorization for the requested host account and sends the person to the
1521
+ * host's admin to approve it. With no host account, sends them to the connect
1522
+ * page first.
1523
+ */
1524
+ export async function beginMcpHostAuthorization(request, config, db, env, options) {
1525
+ const limited = checkMcpRateLimit(request, "host-oauth-authorize", 120);
1526
+ if (limited)
1527
+ return limited;
1528
+ const param = hostAccountParam(options);
1529
+ const input = await authorizationInput(request);
1530
+ if (!input[param]) {
1531
+ const connect = new URL(mcpHostApprovalPath(options, "connectPath"), request.url);
1532
+ for (const [key, value] of Object.entries(input))
1533
+ connect.searchParams.set(key, value);
1534
+ return Response.redirect(connect.toString(), 302);
1535
+ }
1536
+ const validated = await validateAuthorizationInput(request, config, db, input);
1537
+ if (validated instanceof Response)
1538
+ return validated;
1539
+ if (validated.surface.id !== "app")
1540
+ return jsonResponse({ error: "Host-account MCP grants are only supported for the app MCP resource" }, { status: 400 });
1541
+ const resource = validated.values.resource || canonicalMcpResource(request, config);
1542
+ if (resource !== canonicalMcpResource(request, config))
1543
+ return redirectWithError(validated.values.redirect_uri, validated.values.state || null, "invalid_target");
1544
+ const hostAccount = options.normalizeHostAccount(input[param]);
1545
+ const pendingId = randomId("mcp_pending");
1546
+ await db.run(`INSERT INTO "_mcp_pending_authorizations"
1547
+ ("id", "clientId", "redirectUri", "scope", "resource", "state", "codeChallenge", "hostAccount", "expiresAt", "createdAt")
1548
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1549
+ pendingId,
1550
+ validated.values.client_id,
1551
+ validated.values.redirect_uri,
1552
+ JSON.stringify(splitScopes(validated.values.scope)),
1553
+ resource,
1554
+ validated.values.state || null,
1555
+ validated.values.code_challenge,
1556
+ hostAccount,
1557
+ addSeconds(600),
1558
+ nowIso()
1559
+ ]);
1560
+ const redirect = options.approvalUrl({
1561
+ hostAccount,
1562
+ pendingAuthId: pendingId,
1563
+ adminAuthPath: mcpHostApprovalPath(options, "adminAuthPath"),
1564
+ env
1565
+ });
1566
+ return Response.redirect(redirect.toString(), 302);
1567
+ }
1568
+ export async function getMcpPendingAuthorization(db, pendingAuthId) {
1569
+ const row = await db.first(`SELECT p."id", p."clientId", p."redirectUri", p."scope", p."resource", p."state", p."codeChallenge",
1570
+ p."hostAccount", p."expiresAt", p."createdAt", c."clientName" AS "clientName"
1571
+ FROM "_mcp_pending_authorizations" p
1572
+ JOIN "_oauth_clients" c ON c."clientId" = p."clientId"
1573
+ WHERE p."id" = ?`, [pendingAuthId]);
1574
+ if (!row || Date.parse(row.expiresAt) <= wallNowMs())
1575
+ return null;
1576
+ return { ...row, scope: splitScopes(parseJson(row.scope)) };
1577
+ }
1578
+ // Every stored host account went through normalizeHostAccount, so a caller's
1579
+ // value is normalised the same way before it is compared; one it rejects (or
1580
+ // turns into nothing) is an error, never a match on nothing.
1581
+ function requireHostAccount(hostApproval, input) {
1582
+ if (!hostApproval || typeof hostApproval.normalizeHostAccount !== "function") {
1583
+ throw new Error("Pass the app's mcp.hostApproval options (normalizeHostAccount) to normalise the host account");
1584
+ }
1585
+ let normalized;
1586
+ try {
1587
+ normalized = hostApproval.normalizeHostAccount(String(input ?? ""));
1588
+ }
1589
+ catch (error) {
1590
+ throw new Error(`Host account ${JSON.stringify(input)} is not valid: ${error instanceof Error ? error.message : String(error)}`);
1591
+ }
1592
+ if (typeof normalized !== "string" || !normalized)
1593
+ throw new Error(`Host account ${JSON.stringify(input)} is not valid`);
1594
+ return normalized;
1595
+ }
1596
+ /**
1597
+ * Issues the single-use ticket that completes a pending authorization. Call it
1598
+ * from the app's approval page only after verifying the host's own session;
1599
+ * `hostAccount` is the account that session proves, in any form the app's
1600
+ * `normalizeHostAccount` accepts: it is normalised here and must then equal
1601
+ * the pending authorization's account. Pass the same `hostApproval` options
1602
+ * the app gives `mcp.hostApproval`.
1603
+ */
1604
+ export async function createMcpApprovalTicket(db, hostApproval, input) {
1605
+ const hostAccount = requireHostAccount(hostApproval, input.hostAccount);
1606
+ const pending = await getMcpPendingAuthorization(db, input.pendingAuthId);
1607
+ if (!pending)
1608
+ throw new Error("Pending MCP authorization not found or expired");
1609
+ if (pending.hostAccount !== hostAccount) {
1610
+ throw new Error("Pending MCP authorization host account does not match the verified host session");
1611
+ }
1612
+ const ticket = randomToken("rb_mcp_ticket");
1613
+ const expiresAt = addSeconds(300);
1614
+ await db.run(`INSERT INTO "_mcp_approval_tickets" ("id", "ticketHash", "pendingAuthId", "hostAccount", "clientId", "scope", "expiresAt", "createdAt")
1615
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [randomId("mcp_ticket"), await sha256Hex(ticket), pending.id, hostAccount, pending.clientId, JSON.stringify(pending.scope), expiresAt, nowIso()]);
1616
+ return { ticket, expiresAt };
1617
+ }
1618
+ export function topLevelMcpApprovalRedirectHtml(url) {
1619
+ const escaped = escapeHtml(url);
1620
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Completing MCP authorization</title></head><body>
1621
+ <main style="font-family: system-ui, sans-serif; max-width: 560px; margin: 48px auto; line-height: 1.5;">
1622
+ <h1>Completing authorization</h1>
1623
+ <p>If the browser does not continue automatically, use the button below.</p>
1624
+ <p><a target="_top" href="${escaped}">Continue</a></p>
1625
+ </main>
1626
+ <script>window.top.location.href = ${JSON.stringify(url)};</script>
1627
+ </body></html>`;
1628
+ }
1629
+ export function redirectMcpPendingAuthorizationError(pending, error) {
1630
+ return redirectWithError(pending.redirectUri, pending.state, error);
1631
+ }
1632
+ /** Redeems an approval ticket (once) and makes the grant bound to its host account. */
1633
+ export async function completeMcpHostApproval(request, config, db, options) {
1634
+ const limited = checkMcpRateLimit(request, "host-oauth-complete", 120);
1635
+ if (limited)
1636
+ return limited;
1637
+ const ticket = new URL(request.url).searchParams.get("ticket") ?? "";
1638
+ if (!ticket)
1639
+ return jsonResponse({ error: "ticket is required" }, { status: 400 });
1640
+ const consumedAt = nowIso();
1641
+ const rows = await db.all(`UPDATE "_mcp_approval_tickets"
1642
+ SET "consumedAt" = ?
1643
+ WHERE "ticketHash" = ? AND "consumedAt" IS NULL AND "expiresAt" > ?
1644
+ RETURNING "id", "pendingAuthId", "hostAccount", "clientId", "scope", "expiresAt"`, [consumedAt, await sha256Hex(ticket), consumedAt]);
1645
+ const consumed = rows[0];
1646
+ if (!consumed)
1647
+ return jsonResponse({ error: "Approval ticket is invalid, expired, or already used" }, { status: 400 });
1648
+ const pending = await getMcpPendingAuthorization(db, consumed.pendingAuthId);
1649
+ if (!pending || pending.clientId !== consumed.clientId || pending.hostAccount !== consumed.hostAccount) {
1650
+ return jsonResponse({ error: "Pending MCP authorization is no longer valid" }, { status: 400 });
1651
+ }
1652
+ const subject = resolveHostSubject(options, consumed.hostAccount);
1653
+ return createGrantAndCode(db, config, request, hostAccountUser(consumed.hostAccount, subject), {
1654
+ client_id: pending.clientId,
1655
+ redirect_uri: pending.redirectUri,
1656
+ scope: pending.scope.join(" "),
1657
+ resource: pending.resource,
1658
+ state: pending.state ?? "",
1659
+ code_challenge: pending.codeChallenge
1660
+ }, pending.clientName, {}, {}, { account: consumed.hostAccount, subject });
1661
+ }
1662
+ async function loadGrant(db, grantId) {
1663
+ return db.first(`SELECT * FROM "_mcp_grants" WHERE "id" = ?`, [grantId]);
1664
+ }
1665
+ async function loadUser(db, userId) {
1666
+ const row = await db.first(`SELECT "id", "email", "name", "operatorRole", "appRoles", "banned" FROM "_auth_users" WHERE "id" = ?`, [userId]);
1667
+ if (!row || row.banned)
1668
+ return null;
1669
+ const operatorRole = normalizeStoredAdminRole(row.operatorRole);
1670
+ const appRoles = row.appRoles ? normalizeRoles(String(row.appRoles).startsWith("[") ? JSON.parse(row.appRoles) : row.appRoles.split(",")) : ["user"];
1671
+ return withHostUserRoleCeiling({ id: row.id, email: row.email, name: row.name, operatorRole, appRoles });
1672
+ }
1673
+ export async function exchangeMcpOAuthToken(request, config, db, options = {}) {
1674
+ const limited = checkMcpRateLimit(request, "oauth-token", 120);
1675
+ if (limited)
1676
+ return limited;
1677
+ if (!oauthConfig(config).enabled)
1678
+ return jsonResponse({ error: "OAuth is disabled" }, { status: 404 });
1679
+ const body = await formOrJson(request);
1680
+ const grantType = asString(body.grant_type);
1681
+ if (grantType === "authorization_code") {
1682
+ return exchangeAuthorizationCode(request, config, db, body, options);
1683
+ }
1684
+ if (grantType === "refresh_token") {
1685
+ return exchangeRefreshToken(config, db, body, options);
1686
+ }
1687
+ if (grantType === "client_credentials") {
1688
+ return exchangeClientCredentials(request, config, db, body, options);
1689
+ }
1690
+ return jsonResponse({ error: "unsupported_grant_type" }, { status: 400 });
1691
+ }
1692
+ function invalidClientResponse(description) {
1693
+ return jsonResponse({ error: "invalid_client", error_description: description }, { status: 401, headers: { "www-authenticate": 'Basic realm="oauth"' } });
1694
+ }
1695
+ /**
1696
+ * RFC 6749 §4.4. The client authenticates with its secret; the token it gets
1697
+ * carries the grant approved at registration (subject, scopes, resource,
1698
+ * organization). No refresh token is issued: the client re-authenticates
1699
+ * when the access token expires.
1700
+ */
1701
+ async function exchangeClientCredentials(request, config, db, body, options) {
1702
+ if (!oauthConfig(config).clientCredentials)
1703
+ return jsonResponse({ error: "unsupported_grant_type" }, { status: 400 });
1704
+ const credentials = clientCredentialsFromRequest(request, body);
1705
+ if (!credentials)
1706
+ return invalidClientResponse("Client authentication is required");
1707
+ const client = await clientById(db, credentials.clientId);
1708
+ if (!client || client.revokedAt || !isConfidentialClient(client) || !client.grantId)
1709
+ return invalidClientResponse("Unknown or revoked client");
1710
+ if (client.tokenEndpointAuthMethod && client.tokenEndpointAuthMethod !== credentials.method) {
1711
+ return invalidClientResponse(`Client must authenticate with ${client.tokenEndpointAuthMethod}`);
1712
+ }
1713
+ if (!timingSafeEqualStrings(await sha256Hex(credentials.clientSecret), client.clientSecretHash ?? "")) {
1714
+ await audit(db, null, "mcp.client.auth_failed", "_oauth_clients", client.clientId);
1715
+ return invalidClientResponse("Invalid client secret");
1716
+ }
1717
+ const grant = await loadGrant(db, client.grantId);
1718
+ if (!grant || grant.revokedAt || (grant.expiresAt && Date.parse(grant.expiresAt) <= wallNowMs()))
1719
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1720
+ const grantScopes = splitScopes(parseJson(grant.scopes));
1721
+ const requested = splitScopes(asString(body.scope));
1722
+ if (requested.some((scope) => !grantScopes.includes(scope)))
1723
+ return jsonResponse({ error: "invalid_scope" }, { status: 400 });
1724
+ const scopes = requested.length ? requested : grantScopes;
1725
+ const requestedResource = asString(body.resource);
1726
+ if (requestedResource && requestedResource !== grant.resource)
1727
+ return jsonResponse({ error: "invalid_target" }, { status: 400 });
1728
+ if (!grant.userId)
1729
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1730
+ const user = await (options.resolveOAuthUser?.(db, grant.userId) ?? loadUser(db, grant.userId));
1731
+ if (!user)
1732
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1733
+ const issued = await issueAccessToken(config, db, grant.id, client.clientId, grant.userId, scopes, grant.resource);
1734
+ await event(db, grant.id, client.clientId, "token.issued", undefined, { grantType: "client_credentials" });
1735
+ return jsonResponse({
1736
+ access_token: issued.token,
1737
+ token_type: "Bearer",
1738
+ expires_in: oauthConfig(config).accessTokenTtlSeconds,
1739
+ scope: scopes.join(" "),
1740
+ resource: grant.resource
1741
+ });
1742
+ }
1743
+ async function issueAccessToken(config, db, grantId, clientId, userId, scopes, resource) {
1744
+ const access = randomToken("rb_at");
1745
+ const now = nowIso();
1746
+ const expiresAt = addSeconds(oauthConfig(config).accessTokenTtlSeconds);
1747
+ await db.run(`INSERT INTO "_oauth_access_tokens" ("id", "tokenHash", "grantId", "clientId", "userId", "resource", "scope", "expiresAt", "createdAt")
1748
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [randomId("oauth_access"), await sha256Hex(access), grantId, clientId, userId, resource, JSON.stringify(scopes), expiresAt, now]);
1749
+ await db.run(`UPDATE "_mcp_grants" SET "lastUsedAt" = ? WHERE "id" = ?`, [now, grantId]);
1750
+ return { token: access, expiresAt };
1751
+ }
1752
+ async function exchangeAuthorizationCode(request, config, db, body, options) {
1753
+ const code = asString(body.code);
1754
+ const verifier = asString(body.code_verifier);
1755
+ const redirectUri = asString(body.redirect_uri);
1756
+ const clientId = asString(body.client_id);
1757
+ if (!code || !verifier || !redirectUri || !clientId)
1758
+ return jsonResponse({ error: "invalid_request" }, { status: 400 });
1759
+ const row = await db.first(`SELECT * FROM "_oauth_authorization_codes" WHERE "codeHash" = ?`, [await sha256Hex(code)]);
1760
+ if (!row || row.consumedAt || row.clientId !== clientId || row.redirectUri !== redirectUri || Date.parse(row.expiresAt) <= wallNowMs()) {
1761
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1762
+ }
1763
+ if (row.codeChallengeMethod !== "S256" || (await sha256Base64url(verifier)) !== row.codeChallenge) {
1764
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1765
+ }
1766
+ await db.run(`UPDATE "_oauth_authorization_codes" SET "consumedAt" = ? WHERE "id" = ? AND "consumedAt" IS NULL`, [nowIso(), row.id]);
1767
+ return issueTokens(config, db, row.grantId, row.clientId, row.userId, splitScopes(parseJson(row.scope)), row.resource, options);
1768
+ }
1769
+ async function exchangeRefreshToken(config, db, body, options) {
1770
+ const token = asString(body.refresh_token);
1771
+ const clientId = asString(body.client_id);
1772
+ const row = await db.first(`SELECT * FROM "_oauth_refresh_tokens" WHERE "tokenHash" = ?`, [await sha256Hex(token)]);
1773
+ if (!row || row.revokedAt || row.clientId !== clientId || Date.parse(row.expiresAt) <= wallNowMs()) {
1774
+ if (row?.revokedAt || (row && row.clientId !== clientId))
1775
+ await revokeGrantFamily(db, row.grantId, "refresh_reuse");
1776
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1777
+ }
1778
+ const replacement = randomId("oauth_refresh");
1779
+ await db.run(`UPDATE "_oauth_refresh_tokens" SET "revokedAt" = ?, "replacedBy" = ? WHERE "id" = ?`, [nowIso(), replacement, row.id]);
1780
+ const grant = await loadGrant(db, row.grantId);
1781
+ return issueTokens(config, db, row.grantId, row.clientId, row.userId, splitScopes(parseJson(row.scope)), grant?.resource ?? "", options, replacement);
1782
+ }
1783
+ async function revokeGrantFamily(db, grantId, reason) {
1784
+ const grant = await loadGrant(db, grantId);
1785
+ await revokeGrantAndDependents(db, grantId);
1786
+ await event(db, grantId, grant?.clientId ?? "unknown", "grant.revoked", undefined, { reason });
1787
+ }
1788
+ async function issueTokens(config, db, grantId, clientId, userId, scopes, resource, options, refreshTokenId = randomId("oauth_refresh")) {
1789
+ const grant = await loadGrant(db, grantId);
1790
+ if (!grant || grant.revokedAt)
1791
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1792
+ const user = await grantUser(db, grant, userId, options);
1793
+ if (!user)
1794
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1795
+ if (grant.grantType === "client_credentials")
1796
+ return jsonResponse({ error: "invalid_grant" }, { status: 400 });
1797
+ const { token: access } = await issueAccessToken(config, db, grantId, clientId, userId, scopes, resource);
1798
+ const refresh = randomToken("rb_rt");
1799
+ const now = nowIso();
1800
+ const refreshExpiresAt = addSeconds(oauthConfig(config).refreshTokenTtlSeconds);
1801
+ await db.run(`INSERT INTO "_oauth_refresh_tokens" ("id", "tokenHash", "grantId", "clientId", "userId", "scope", "expiresAt", "createdAt")
1802
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [refreshTokenId, await sha256Hex(refresh), grantId, clientId, userId, JSON.stringify(scopes), refreshExpiresAt, now]);
1803
+ return jsonResponse({
1804
+ access_token: access,
1805
+ token_type: "Bearer",
1806
+ expires_in: oauthConfig(config).accessTokenTtlSeconds,
1807
+ refresh_token: refresh,
1808
+ scope: scopes.join(" "),
1809
+ resource
1810
+ });
1811
+ }
1812
+ export async function revokeMcpOAuthToken(request, db) {
1813
+ const limited = checkMcpRateLimit(request, "oauth-revoke", 120);
1814
+ if (limited)
1815
+ return limited;
1816
+ const body = await formOrJson(request);
1817
+ const token = asString(body.token);
1818
+ if (!token)
1819
+ return jsonResponse({ error: "token is required" }, { status: 400 });
1820
+ const tokenHash = await sha256Hex(token);
1821
+ const now = nowIso();
1822
+ await db.run(`UPDATE "_oauth_access_tokens" SET "revokedAt" = ? WHERE "tokenHash" = ? AND "revokedAt" IS NULL`, [now, tokenHash]);
1823
+ await db.run(`UPDATE "_oauth_refresh_tokens" SET "revokedAt" = ? WHERE "tokenHash" = ? AND "revokedAt" IS NULL`, [now, tokenHash]);
1824
+ return new Response(null, { status: 200 });
1825
+ }
1826
+ export async function validateMcpBearerToken(request, config, db, expectedSurface = "app", options = {}, resourceOverride) {
1827
+ const header = request.headers.get("authorization") ?? "";
1828
+ const match = /^Bearer\s+(.+)$/i.exec(header);
1829
+ if (!match) {
1830
+ return unauthorizedMcpResponse(request, config, "Unauthorized", expectedSurface);
1831
+ }
1832
+ const tokenHash = await sha256Hex(match[1]);
1833
+ const row = await db.first(`SELECT * FROM "_oauth_access_tokens" WHERE "tokenHash" = ?`, [tokenHash]);
1834
+ if (!row || row.revokedAt || Date.parse(row.expiresAt) <= wallNowMs()) {
1835
+ return unauthorizedMcpResponse(request, config, "Invalid or expired token", expectedSurface);
1836
+ }
1837
+ const expectedResource = resourceOverride ?? canonicalMcpResourceForSurface(request, config, expectedSurface);
1838
+ if (row.resource !== expectedResource)
1839
+ return unauthorizedMcpResponse(request, config, "Token audience does not match this MCP resource", expectedSurface);
1840
+ const grant = await loadGrant(db, row.grantId);
1841
+ if (!grant || grant.revokedAt || grant.clientId !== row.clientId || grant.resource !== expectedResource) {
1842
+ return unauthorizedMcpResponse(request, config, "MCP grant is no longer active", expectedSurface);
1843
+ }
1844
+ if (grant.expiresAt && Date.parse(grant.expiresAt) <= wallNowMs())
1845
+ return unauthorizedMcpResponse(request, config, "MCP grant has expired", expectedSurface);
1846
+ const user = await grantUser(db, grant, row.userId, options);
1847
+ if (!user)
1848
+ return unauthorizedMcpResponse(request, config, "MCP subject is no longer valid", expectedSurface);
1849
+ const resourceRequest = resourceOverride ? new Request(expectedResource) : request;
1850
+ const surface = mcpSurfaceForResource(resourceRequest, config, expectedResource, options);
1851
+ if (!surface)
1852
+ return unauthorizedMcpResponse(request, config, "Unknown MCP resource", expectedSurface);
1853
+ if (surface.id === "operator") {
1854
+ if (grant.subjectType !== "raybase-operator" || grant.subjectId !== user.id) {
1855
+ return unauthorizedMcpResponse(request, config, "MCP grant is not valid for Operator MCP", expectedSurface);
1856
+ }
1857
+ const grantScopes = splitScopes(parseJson(grant.scopes));
1858
+ if (authorizeMcpScopes(user, grantScopes, surface, options)) {
1859
+ return unauthorizedMcpResponse(request, config, "Operator MCP grant is no longer authorized", expectedSurface);
1860
+ }
1861
+ }
1862
+ else if (grant.subjectType === "raybase-operator" || row.scope.includes("operator:")) {
1863
+ return unauthorizedMcpResponse(request, config, "Operator MCP tokens cannot be used on the app MCP resource", expectedSurface);
1864
+ }
1865
+ const tokenHostAccount = grant.hostAccount || undefined;
1866
+ if (options.hostApproval) {
1867
+ // A request that names a host account must name the token's.
1868
+ const requested = new URL(request.url).searchParams.get(hostAccountParam(options.hostApproval));
1869
+ if (requested) {
1870
+ let normalized;
1871
+ try {
1872
+ normalized = options.hostApproval.normalizeHostAccount(requested);
1873
+ }
1874
+ catch {
1875
+ normalized = null;
1876
+ }
1877
+ if (normalized !== tokenHostAccount)
1878
+ return jsonResponse({ error: "Requested host account does not match the token's host account" }, { status: 403 });
1879
+ }
1880
+ }
1881
+ const now = nowIso();
1882
+ await db.run(`UPDATE "_oauth_access_tokens" SET "lastUsedAt" = ? WHERE "id" = ?`, [now, row.id]);
1883
+ await db.run(`UPDATE "_mcp_grants" SET "lastUsedAt" = ? WHERE "id" = ?`, [now, grant.id]);
1884
+ return {
1885
+ tokenId: row.id,
1886
+ grantId: grant.id,
1887
+ user,
1888
+ subjectType: grant.subjectType,
1889
+ subjectId: grant.subjectId,
1890
+ clientId: row.clientId,
1891
+ clientName: grant.clientName,
1892
+ scopes: splitScopes(parseJson(row.scope)),
1893
+ resource: row.resource,
1894
+ hostAccount: tokenHostAccount,
1895
+ organizationId: grant.organizationId ?? null,
1896
+ grantType: grant.grantType === "client_credentials" ? "client_credentials" : "authorization_code"
1897
+ };
1898
+ }
1899
+ function jsonSchema(inputSchema) {
1900
+ return jsonSchemaFor(inputSchema);
1901
+ }
1902
+ function mcpToolMeta(tool) {
1903
+ if (!tool._meta)
1904
+ return undefined;
1905
+ const meta = { ...tool._meta };
1906
+ if (meta.ui) {
1907
+ meta.ui = {
1908
+ ...meta.ui,
1909
+ visibility: toolMcpAppVisibility(tool)
1910
+ };
1911
+ }
1912
+ return meta;
1913
+ }
1914
+ function mcpToolDescriptor(tool) {
1915
+ return {
1916
+ name: tool.name,
1917
+ description: tool.description,
1918
+ inputSchema: jsonSchema(tool.inputSchema),
1919
+ annotations: tool.annotations,
1920
+ _meta: mcpToolMeta(tool)
1921
+ };
1922
+ }
1923
+ function parseToolArgs(tool, raw) {
1924
+ if (typeof tool.inputSchema.parse === "function")
1925
+ return tool.inputSchema.parse(raw);
1926
+ return raw ?? {};
1927
+ }
1928
+ function rpcResult(id, result) {
1929
+ return { jsonrpc: "2.0", id: id ?? null, result };
1930
+ }
1931
+ function rpcError(id, code, message, data) {
1932
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message, data } };
1933
+ }
1934
+ function isJsonRpcRequest(value) {
1935
+ const candidate = asRecord(value);
1936
+ return candidate.jsonrpc === "2.0" && typeof candidate.method === "string";
1937
+ }
1938
+ function zodListSchema() {
1939
+ return z
1940
+ .object({
1941
+ limit: z.coerce.number().int().min(1).max(500).optional(),
1942
+ offset: z.coerce.number().int().min(0).optional(),
1943
+ search: z.string().optional(),
1944
+ filter: z.record(z.string(), z.unknown()).optional(),
1945
+ sort: z.object({ column: z.string(), direction: z.enum(["asc", "desc"]).optional() }).optional()
1946
+ })
1947
+ .strict();
1948
+ }
1949
+ function zodIdSchema() {
1950
+ return z.object({ id: z.string().min(1) }).strict();
1951
+ }
1952
+ function generatedToolName(action, table) {
1953
+ const singular = table.name.endsWith("s") ? table.name.slice(0, -1) : table.name;
1954
+ return action === "list" ? `list_${table.name}` : `${action}_${singular}`;
1955
+ }
1956
+ export function generatedMcpTools(schema, config) {
1957
+ const tools = [];
1958
+ for (const [tableKey, actions] of Object.entries(config?.tables ?? {})) {
1959
+ const entry = schema.tables[tableKey]
1960
+ ? [tableKey, schema.tables[tableKey]]
1961
+ : Object.entries(schema.tables).find(([, candidate]) => candidate.name === tableKey);
1962
+ const table = entry?.[1];
1963
+ if (!table)
1964
+ throw new Error(`Unknown table for generated MCP tools: ${tableKey}`);
1965
+ const clientTableKey = entry[0];
1966
+ for (const action of actions) {
1967
+ const name = generatedToolName(action, table);
1968
+ if (action === "list") {
1969
+ tools.push({
1970
+ name,
1971
+ description: `List ${table.label ?? table.name}`,
1972
+ inputSchema: zodListSchema(),
1973
+ annotations: { readOnlyHint: true },
1974
+ scopes: ["mcp:read", `mcp:table:${table.name}:read`, `mcp:tool:${name}`],
1975
+ handler: async (args, ctx) => {
1976
+ const input = asRecord(args);
1977
+ const result = await ctx.db.list(clientTableKey, input);
1978
+ return { content: [{ type: "text", text: `Found ${result.total} ${table.name} row(s).` }], structuredContent: result };
1979
+ }
1980
+ });
1981
+ }
1982
+ else if (action === "get") {
1983
+ tools.push({
1984
+ name,
1985
+ description: `Get one ${table.label ?? table.name} row`,
1986
+ inputSchema: zodIdSchema(),
1987
+ annotations: { readOnlyHint: true },
1988
+ scopes: ["mcp:read", `mcp:table:${table.name}:read`, `mcp:tool:${name}`],
1989
+ handler: async (args, ctx) => {
1990
+ const row = await ctx.db.get(clientTableKey, String(asRecord(args).id));
1991
+ return row
1992
+ ? { content: [{ type: "text", text: `Loaded ${table.name} ${row.id}.` }], structuredContent: row }
1993
+ : { content: [{ type: "text", text: `${table.name} row not found.` }], isError: true };
1994
+ }
1995
+ });
1996
+ }
1997
+ else if (action === "create") {
1998
+ tools.push({
1999
+ name,
2000
+ description: `Create a ${table.label ?? table.name} row`,
2001
+ inputSchema: inputSchemaForTable(table, "create"),
2002
+ annotations: { idempotentHint: false },
2003
+ scopes: ["mcp:write", `mcp:table:${table.name}:write`, `mcp:tool:${name}`],
2004
+ handler: async (args, ctx) => {
2005
+ const row = await ctx.db.create(clientTableKey, asRecord(args));
2006
+ return { content: [{ type: "text", text: `Created ${table.name} ${row.id}.` }], structuredContent: row };
2007
+ }
2008
+ });
2009
+ }
2010
+ else if (action === "update") {
2011
+ tools.push({
2012
+ name,
2013
+ description: `Update a ${table.label ?? table.name} row`,
2014
+ inputSchema: z.object({ id: z.string().min(1), patch: inputSchemaForTable(table, "update").partial() }).strict(),
2015
+ annotations: { idempotentHint: true },
2016
+ scopes: ["mcp:write", `mcp:table:${table.name}:write`, `mcp:tool:${name}`],
2017
+ handler: async (args, ctx) => {
2018
+ const input = asRecord(args);
2019
+ const row = await ctx.db.update(clientTableKey, String(input.id), asRecord(input.patch));
2020
+ return { content: [{ type: "text", text: `Updated ${table.name} ${row.id}.` }], structuredContent: row };
2021
+ }
2022
+ });
2023
+ }
2024
+ else if (action === "delete") {
2025
+ tools.push({
2026
+ name,
2027
+ description: `Delete a ${table.label ?? table.name} row`,
2028
+ inputSchema: zodIdSchema(),
2029
+ annotations: { destructiveHint: true, idempotentHint: false },
2030
+ scopes: ["mcp:write", `mcp:table:${table.name}:write`, `mcp:tool:${name}`],
2031
+ handler: async (args, ctx) => {
2032
+ const id = String(asRecord(args).id);
2033
+ await ctx.db.delete(clientTableKey, id);
2034
+ return { content: [{ type: "text", text: `Deleted ${table.name} ${id}.` }], structuredContent: { ok: true } };
2035
+ }
2036
+ });
2037
+ }
2038
+ }
2039
+ }
2040
+ return tools;
2041
+ }
2042
+ function generatedSearchMcpTools(options) {
2043
+ if (!options.search)
2044
+ return [];
2045
+ const tools = [];
2046
+ const usedNames = new Set();
2047
+ const inputSchema = z
2048
+ .object({
2049
+ query: z.string().min(1),
2050
+ limit: z.coerce.number().int().min(1).max(20).default(10)
2051
+ })
2052
+ .strict();
2053
+ const pushTool = (tool) => {
2054
+ if (usedNames.has(tool.name))
2055
+ return;
2056
+ usedNames.add(tool.name);
2057
+ tools.push(tool);
2058
+ };
2059
+ for (const index of Object.values(options.search.indexes)) {
2060
+ pushTool({
2061
+ name: mcpToolName(`search_${index.name}`),
2062
+ description: `Search ${index.title ?? index.name}`,
2063
+ inputSchema,
2064
+ annotations: { readOnlyHint: true },
2065
+ scopes: ["mcp:read", "mcp:search:read", `mcp:tool:${mcpToolName(`search_${index.name}`)}`],
2066
+ handler: async (args, ctx) => {
2067
+ const input = asRecord(args);
2068
+ const db = createRaybaseDb(ctx.env, options.schema, options.appMigrations);
2069
+ const result = await searchIndex({
2070
+ schema: options.schema,
2071
+ db: ctx.db,
2072
+ control: db.control,
2073
+ search: options.search,
2074
+ indexName: index.name,
2075
+ query: String(input.query ?? ""),
2076
+ limit: Number(input.limit ?? 10)
2077
+ });
2078
+ return {
2079
+ content: [{ type: "text", text: `Found ${result.results.length} search result(s).` }],
2080
+ structuredContent: { results: result.results, query: result.query?.raw ?? "" }
2081
+ };
2082
+ }
2083
+ });
2084
+ const tableSources = index.sources.filter((source) => source.kind === "table");
2085
+ for (const source of tableSources) {
2086
+ const name = mcpToolName(`search_${source.table.name}`);
2087
+ pushTool({
2088
+ name,
2089
+ description: `Search ${source.table.label ?? source.table.name}`,
2090
+ inputSchema,
2091
+ annotations: { readOnlyHint: true },
2092
+ scopes: ["mcp:read", "mcp:search:read", `mcp:table:${source.table.name}:read`, `mcp:tool:${name}`],
2093
+ handler: async (args, ctx) => {
2094
+ const input = asRecord(args);
2095
+ const db = createRaybaseDb(ctx.env, options.schema, options.appMigrations);
2096
+ const result = await searchIndex({
2097
+ schema: options.schema,
2098
+ db: ctx.db,
2099
+ control: db.control,
2100
+ search: options.search,
2101
+ indexName: index.name,
2102
+ query: String(input.query ?? ""),
2103
+ limit: Number(input.limit ?? 10),
2104
+ sourceTables: [source.table.name]
2105
+ });
2106
+ return {
2107
+ content: [{ type: "text", text: `Found ${result.results.length} ${source.table.name} result(s).` }],
2108
+ structuredContent: { results: result.results, query: result.query?.raw ?? "" }
2109
+ };
2110
+ }
2111
+ });
2112
+ }
2113
+ }
2114
+ return tools;
2115
+ }
2116
+ function mcpToolName(value) {
2117
+ return value.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "search";
2118
+ }
2119
+ async function event(db, grantId, clientId, action, toolName, metadata) {
2120
+ await db.run(`INSERT INTO "_mcp_grant_events" ("id", "grantId", "clientId", "action", "toolName", "metadata", "createdAt")
2121
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [randomId("mcp_event"), grantId, clientId, action, toolName ?? null, metadata === undefined ? null : JSON.stringify(metadata), nowIso()]);
2122
+ }
2123
+ export function createMcpHandler(options) {
2124
+ const surface = options.surface ?? "app";
2125
+ const secretColumns = secretColumnKeys(options.schema, options.systemTables);
2126
+ const allTools = [
2127
+ ...generatedMcpTools(options.schema, options.generatedTools),
2128
+ ...generatedSearchMcpTools(options),
2129
+ ...(options.tools ?? [])
2130
+ ];
2131
+ const resources = options.resources ?? [];
2132
+ for (const resource of resources)
2133
+ validateMcpAppResource(resource);
2134
+ for (const tool of allTools)
2135
+ validateMcpAppTool(tool, resources);
2136
+ const mcpAppUiEnabled = surface === "app" && supportsMcpAppUi(allTools, resources);
2137
+ return async function handleMcp(request, env) {
2138
+ if (request.method === "OPTIONS")
2139
+ return new Response(null, { status: 204, headers: corsHeaders(request, options.config) });
2140
+ if (request.method !== "POST")
2141
+ return methodNotAllowed();
2142
+ const originError = validateOrigin(request, options.config);
2143
+ if (originError)
2144
+ return originError;
2145
+ const acceptError = validateAccept(request);
2146
+ if (acceptError)
2147
+ return acceptError;
2148
+ let runtimeEnv = env;
2149
+ const realtimeEnabled = options.realtime?.config.enabled === true;
2150
+ const expectedResource = canonicalMcpResourceForSurface(request, options.config, surface);
2151
+ let token;
2152
+ if (options.validateAccessToken) {
2153
+ token = await options.validateAccessToken({
2154
+ request,
2155
+ env,
2156
+ surface,
2157
+ expectedResource
2158
+ });
2159
+ }
2160
+ else {
2161
+ const initialDb = createRaybaseDb(runtimeEnv, options.schema, options.appMigrations);
2162
+ await ensureMcpSystemTables(initialDb.control);
2163
+ token = await validateMcpBearerToken(request, options.config, initialDb.control, surface, options.scopeOptions);
2164
+ }
2165
+ if (token instanceof Response)
2166
+ return token;
2167
+ runtimeEnv = await options.resolveRuntimeEnv?.({ request, env, token, surface }) ?? env;
2168
+ const dbEnv = runtimeEnv;
2169
+ const db = createRaybaseDb(dbEnv, options.schema, options.appMigrations, {
2170
+ ...(realtimeEnabled ? { realtime: options.realtime?.config } : {}),
2171
+ ...(options.search
2172
+ ? {
2173
+ afterMutation: async (change) => {
2174
+ const searchDb = createRaybaseDb(dbEnv, options.schema, options.appMigrations);
2175
+ await updateSearchForDataChange({
2176
+ schema: options.schema,
2177
+ control: searchDb.control,
2178
+ search: options.search,
2179
+ change
2180
+ });
2181
+ }
2182
+ }
2183
+ : {})
2184
+ });
2185
+ const operatorRequest = token.scopes.some(isOperatorScope);
2186
+ const limited = checkMcpRateLimit(request, operatorRequest ? `mcp-operator:${token.grantId}:${token.clientId}` : `mcp:${token.grantId}:${token.clientId}`, operatorRequest ? 120 : 300);
2187
+ if (limited)
2188
+ return limited;
2189
+ const mcpContext = {
2190
+ clientId: token.clientId,
2191
+ clientName: token.clientName,
2192
+ grantId: token.grantId,
2193
+ scopes: token.scopes,
2194
+ subject: token.subjectId,
2195
+ hostAccount: token.hostAccount,
2196
+ organizationId: token.organizationId ?? null,
2197
+ auditMetadata: {
2198
+ mcpGrantId: token.grantId,
2199
+ mcpClientId: token.clientId,
2200
+ mcpClientName: token.clientName,
2201
+ mcpScopes: token.scopes,
2202
+ mcpSubjectType: token.subjectType,
2203
+ mcpSubject: token.subjectId,
2204
+ mcpOrganizationId: token.organizationId ?? null,
2205
+ hostAccount: token.hostAccount
2206
+ }
2207
+ };
2208
+ try {
2209
+ const extra = await options.resolveContext?.({ request, env: runtimeEnv, token });
2210
+ Object.assign(mcpContext, extra ?? {});
2211
+ }
2212
+ catch (error) {
2213
+ return jsonResponse({ error: error instanceof Error ? error.message : "MCP context rejected" }, { status: 403 });
2214
+ }
2215
+ const client = db.for({
2216
+ user: token.user,
2217
+ source: { surface: "mcp", requestId: request.headers.get("cf-ray") ?? undefined },
2218
+ audit: (entry) => audit(db.control, token.user.id, entry.action, entry.target.type, entry.target.id ?? undefined, { ...asRecord(entry.metadata), ...mcpContext.auditMetadata }),
2219
+ notify: realtimeEnabled
2220
+ ? async (change) => {
2221
+ if (!("seq" in change))
2222
+ return;
2223
+ await dataStub(dbEnv).notifyDataChange({
2224
+ change: change,
2225
+ config: options.realtime.config,
2226
+ schema: options.realtime.schema
2227
+ });
2228
+ }
2229
+ : undefined,
2230
+ realtime: options.realtime?.config
2231
+ });
2232
+ const extraToolContext = await options.toolContext?.({
2233
+ request,
2234
+ env: runtimeEnv,
2235
+ user: token.user,
2236
+ control: db.control,
2237
+ mcp: mcpContext
2238
+ });
2239
+ const toolCtx = {
2240
+ ...extraToolContext,
2241
+ request,
2242
+ env: runtimeEnv,
2243
+ config: options.config,
2244
+ user: token.user,
2245
+ principal: token.user,
2246
+ db: client,
2247
+ control: db.control,
2248
+ mcp: mcpContext,
2249
+ audit: (action, metadata) => options.recordEvent
2250
+ ? options.recordEvent({ env: runtimeEnv, token, action, metadata })
2251
+ : event(db.control, mcpContext.grantId, mcpContext.clientId, action, undefined, metadata)
2252
+ };
2253
+ const body = await request.json().catch(() => null);
2254
+ const requests = Array.isArray(body) ? body : [body];
2255
+ const responses = [];
2256
+ for (const rpc of requests) {
2257
+ if (!isJsonRpcRequest(rpc)) {
2258
+ responses.push(rpcError(null, -32600, "Invalid JSON-RPC request"));
2259
+ continue;
2260
+ }
2261
+ const response = await handleRpc(rpc, allTools, resources, toolCtx, db.control, surface, mcpAppUiEnabled, secretColumns, options.recordEvent
2262
+ ? (action, toolName, metadata) => options.recordEvent({ env: runtimeEnv, token, action, toolName, metadata })
2263
+ : undefined, options.recordOperatorAudit
2264
+ ? (action, targetType, targetId, metadata) => options.recordOperatorAudit({
2265
+ env: runtimeEnv,
2266
+ token,
2267
+ action,
2268
+ targetType,
2269
+ targetId,
2270
+ metadata
2271
+ })
2272
+ : undefined);
2273
+ if (rpc.id !== undefined)
2274
+ responses.push(response);
2275
+ }
2276
+ if (responses.length === 0)
2277
+ return new Response(null, { status: 202, headers: corsHeaders(request, options.config) });
2278
+ const headers = corsHeaders(request, options.config);
2279
+ headers.set("content-type", "application/json; charset=utf-8");
2280
+ return new Response(JSON.stringify(Array.isArray(body) ? responses : responses[0]), { headers });
2281
+ };
2282
+ }
2283
+ function hasOperatorScope(scopes) {
2284
+ return (scopes ?? []).some(isOperatorScope);
2285
+ }
2286
+ // Tool arguments reach the audit trail by key name. A secret() column can
2287
+ // have any name, so its keys are redacted first.
2288
+ function auditArguments(secretColumns, args) {
2289
+ return sanitizeAuditValue(redactSecretColumnKeys(secretColumns, args));
2290
+ }
2291
+ function sanitizeAuditValue(value, depth = 0) {
2292
+ if (depth > 4)
2293
+ return "[truncated]";
2294
+ if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean")
2295
+ return value;
2296
+ if (typeof value === "string")
2297
+ return value.length > 500 ? `${value.slice(0, 500)}...[truncated]` : value;
2298
+ if (Array.isArray(value))
2299
+ return value.slice(0, 50).map((item) => sanitizeAuditValue(item, depth + 1));
2300
+ const output = {};
2301
+ for (const [key, entry] of Object.entries(asRecord(value))) {
2302
+ if (/password|token|secret|accessToken|refreshToken|idToken|codeHash|tokenHash|publicKey|credential/i.test(key)) {
2303
+ output[key] = "[redacted]";
2304
+ }
2305
+ else {
2306
+ output[key] = sanitizeAuditValue(entry, depth + 1);
2307
+ }
2308
+ }
2309
+ return output;
2310
+ }
2311
+ function operatorResultAuditMetadata(ctx, secretColumns, name, args, result) {
2312
+ const content = asRecord(result?.structuredContent);
2313
+ return {
2314
+ ...ctx.mcp.auditMetadata,
2315
+ toolName: name,
2316
+ arguments: auditArguments(secretColumns, args),
2317
+ appliedFilters: sanitizeAuditValue(content.appliedFilters),
2318
+ rowCount: content.rowCount,
2319
+ returned: content.returned,
2320
+ truncated: content.truncated
2321
+ };
2322
+ }
2323
+ function operatorErrorAuditMetadata(ctx, secretColumns, name, args, error) {
2324
+ return {
2325
+ ...ctx.mcp.auditMetadata,
2326
+ toolName: name,
2327
+ arguments: auditArguments(secretColumns, args),
2328
+ error: error instanceof Error ? error.message : String(error)
2329
+ };
2330
+ }
2331
+ async function handleRpc(rpc, tools, resources, ctx, control, surface, mcpAppUiEnabled, secretColumns, recordEvent, recordOperatorAudit) {
2332
+ try {
2333
+ switch (rpc.method) {
2334
+ case "initialize":
2335
+ const capabilities = {
2336
+ tools: {},
2337
+ resources: {}
2338
+ };
2339
+ if (surface === "app" && mcpAppUiEnabled && clientAcceptsMcpAppUi(rpc.params)) {
2340
+ capabilities.extensions = { [mcpAppsExtensionId]: {} };
2341
+ }
2342
+ return rpcResult(rpc.id, {
2343
+ protocolVersion: "2025-06-18",
2344
+ capabilities,
2345
+ serverInfo: { name: "raybase", title: ctx.config ? mcpConnectorName(ctx.config) : undefined, version: "0.1.0" },
2346
+ ...(surface === "app" && ctx.config && mcpInstructions(ctx.config) ? { instructions: mcpInstructions(ctx.config) } : {})
2347
+ });
2348
+ case "ping":
2349
+ return rpcResult(rpc.id, {});
2350
+ case "tools/list": {
2351
+ const visibleTools = tools.filter((tool) => hasAnyScope(ctx.mcp.scopes, requiredScopesForTool(tool)));
2352
+ return rpcResult(rpc.id, { tools: visibleTools.map(mcpToolDescriptor) });
2353
+ }
2354
+ case "tools/call": {
2355
+ const params = asRecord(rpc.params);
2356
+ const name = asString(params.name);
2357
+ const tool = tools.find((candidate) => candidate.name === name);
2358
+ if (!tool)
2359
+ return rpcError(rpc.id, -32602, `Unknown tool: ${name}`);
2360
+ if (!hasAnyScope(ctx.mcp.scopes, requiredScopesForTool(tool))) {
2361
+ if (surface === "operator" && hasOperatorScope(tool.scopes)) {
2362
+ const metadata = operatorErrorAuditMetadata(ctx, secretColumns, name, params.arguments ?? {}, new Error(`Token scope does not allow tool: ${name}`));
2363
+ await (recordOperatorAudit
2364
+ ? recordOperatorAudit("operator_mcp.tool.error", "_mcp_grants", ctx.mcp.grantId, metadata)
2365
+ : audit(control, ctx.user.id, "operator_mcp.tool.error", "_mcp_grants", ctx.mcp.grantId, metadata)).catch(() => { });
2366
+ }
2367
+ return rpcError(rpc.id, -32001, `Token scope does not allow tool: ${name}`);
2368
+ }
2369
+ let args = params.arguments ?? {};
2370
+ try {
2371
+ args = parseToolArgs(tool, params.arguments ?? {});
2372
+ if (recordEvent) {
2373
+ await recordEvent("tool.call", name, { arguments: auditArguments(secretColumns, args) });
2374
+ }
2375
+ else {
2376
+ await event(control, ctx.mcp.grantId, ctx.mcp.clientId, "tool.call", name, { arguments: auditArguments(secretColumns, args) });
2377
+ }
2378
+ const result = await tool.handler(args, ctx);
2379
+ if (hasOperatorScope(tool.scopes)) {
2380
+ const metadata = operatorResultAuditMetadata(ctx, secretColumns, name, args, result);
2381
+ await (recordOperatorAudit
2382
+ ? recordOperatorAudit("operator_mcp.tool.call", "_mcp_grants", ctx.mcp.grantId, metadata)
2383
+ : audit(control, ctx.user.id, "operator_mcp.tool.call", "_mcp_grants", ctx.mcp.grantId, metadata));
2384
+ }
2385
+ return rpcResult(rpc.id, result);
2386
+ }
2387
+ catch (error) {
2388
+ if (surface === "operator" && hasOperatorScope(tool.scopes)) {
2389
+ const metadata = operatorErrorAuditMetadata(ctx, secretColumns, name, args, error);
2390
+ await (recordOperatorAudit
2391
+ ? recordOperatorAudit("operator_mcp.tool.error", "_mcp_grants", ctx.mcp.grantId, metadata)
2392
+ : audit(control, ctx.user.id, "operator_mcp.tool.error", "_mcp_grants", ctx.mcp.grantId, metadata)).catch(() => { });
2393
+ }
2394
+ return rpcError(rpc.id, -32000, error instanceof Error ? error.message : "MCP request failed");
2395
+ }
2396
+ }
2397
+ case "resources/list": {
2398
+ const visibleResources = resources.filter((resource) => hasAnyScope(ctx.mcp.scopes, requiredScopesForResource(resource)));
2399
+ return rpcResult(rpc.id, {
2400
+ resources: visibleResources.map((resource) => ({
2401
+ uri: resource.uri,
2402
+ name: resource.name,
2403
+ description: resource.description,
2404
+ mimeType: resource.mimeType,
2405
+ _meta: resource._meta
2406
+ }))
2407
+ });
2408
+ }
2409
+ case "resources/read": {
2410
+ const uri = asString(asRecord(rpc.params).uri);
2411
+ const resource = resources.find((candidate) => candidate.uri === uri);
2412
+ if (!resource)
2413
+ return rpcError(rpc.id, -32602, `Unknown resource: ${uri}`);
2414
+ if (!hasAnyScope(ctx.mcp.scopes, requiredScopesForResource(resource)))
2415
+ return rpcError(rpc.id, -32001, "Token scope does not allow resources/read");
2416
+ const read = normalizeMcpAppResourceRead(resource, await resource.read(ctx));
2417
+ if (recordEvent) {
2418
+ await recordEvent("resource.read", undefined, { uri });
2419
+ }
2420
+ else {
2421
+ await event(control, ctx.mcp.grantId, ctx.mcp.clientId, "resource.read", undefined, { uri });
2422
+ }
2423
+ if (hasOperatorScope(resource.scopes)) {
2424
+ await audit(control, ctx.user.id, "operator_mcp.resource.read", "_mcp_grants", ctx.mcp.grantId, {
2425
+ ...ctx.mcp.auditMetadata,
2426
+ uri
2427
+ });
2428
+ }
2429
+ return rpcResult(rpc.id, { contents: [read] });
2430
+ }
2431
+ default:
2432
+ return rpcError(rpc.id, -32601, `Method not found: ${rpc.method}`);
2433
+ }
2434
+ }
2435
+ catch (error) {
2436
+ return rpcError(rpc.id, -32000, error instanceof Error ? error.message : "MCP request failed");
2437
+ }
2438
+ }
2439
+ export async function listMcpGrants(db) {
2440
+ await ensureMcpSystemTables(db);
2441
+ return db.all(`SELECT "id", "subjectType", "subjectId", "userId", "hostAccount", "clientId", "clientName", "scopes", "resource", "createdAt", "expiresAt", "revokedAt", "lastUsedAt", "organizationId", "grantType"
2442
+ FROM "_mcp_grants" ORDER BY "createdAt" DESC LIMIT 500`);
2443
+ }
2444
+ /**
2445
+ * Revokes one grant and everything that lives off it: its access and refresh
2446
+ * tokens, and any confidential client bound to it. Every revoke path goes
2447
+ * through here so they cannot drift; callers record their own audit or event.
2448
+ */
2449
+ async function revokeGrantAndDependents(db, grantId) {
2450
+ const now = nowIso();
2451
+ await db.run(`UPDATE "_mcp_grants" SET "revokedAt" = ? WHERE "id" = ? AND "revokedAt" IS NULL`, [now, grantId]);
2452
+ await db.run(`UPDATE "_oauth_access_tokens" SET "revokedAt" = ? WHERE "grantId" = ? AND "revokedAt" IS NULL`, [now, grantId]);
2453
+ await db.run(`UPDATE "_oauth_refresh_tokens" SET "revokedAt" = ? WHERE "grantId" = ? AND "revokedAt" IS NULL`, [now, grantId]);
2454
+ // A confidential client is only ever as alive as its grant.
2455
+ await db.run(`UPDATE "_oauth_clients" SET "revokedAt" = ? WHERE "grantId" = ? AND "revokedAt" IS NULL`, [now, grantId]);
2456
+ }
2457
+ export async function revokeMcpGrant(db, grantId, actorId) {
2458
+ await ensureMcpSystemTables(db);
2459
+ await revokeGrantAndDependents(db, grantId);
2460
+ await audit(db, actorId, "mcp.grant.revoke", "_mcp_grants", grantId);
2461
+ }
2462
+ /**
2463
+ * Revokes every live grant bound to a host account, and their tokens, for
2464
+ * example when the host reports that the account uninstalled the app.
2465
+ * `hostAccount` may be in any form the app's `normalizeHostAccount` accepts;
2466
+ * it is normalised here (pass the same `hostApproval` options the app gives
2467
+ * `mcp.hostApproval`), and a value it rejects throws. Returns how many grants
2468
+ * were revoked.
2469
+ */
2470
+ export async function revokeMcpGrantsForHostAccount(db, hostApproval, hostAccountInput, actorId = null) {
2471
+ const hostAccount = requireHostAccount(hostApproval, hostAccountInput);
2472
+ await ensureMcpSystemTables(db);
2473
+ const grants = await db.all(`SELECT "id" FROM "_mcp_grants" WHERE "hostAccount" = ? AND "revokedAt" IS NULL`, [hostAccount]);
2474
+ for (const grant of grants) {
2475
+ await revokeGrantAndDependents(db, grant.id);
2476
+ await audit(db, actorId, "mcp.grant.revoke.host_account", "_mcp_grants", grant.id, { hostAccount });
2477
+ }
2478
+ return grants.length;
2479
+ }
2480
+ //# sourceMappingURL=mcp.js.map