@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
package/src/mcp/api.ts ADDED
@@ -0,0 +1,995 @@
1
+ /**
2
+ * MCP emulator route table (internal): one wire route per recorded HTTP shape (`POST /modern/mcp`,
3
+ * `POST /legacy/mcp`, `GET /legacy/mcp`), each answering only the JSON-RPC methods its profile's
4
+ * fixtures record, as manifest variants (`RPC <origin><path>#<method>`, plus the `GET` row).
5
+ *
6
+ * A request is admitted only when it equals a recorded request (`src/mcp/recordings.ts`) within
7
+ * the latitude: the MCP headers (`accept`, `content-type`, `mcp-method`, `mcp-protocol-version`,
8
+ * `mcp-name`, `last-event-id`) equal the recorded values or are absent where the recording has
9
+ * none; `mcp-session-id` is present exactly where recorded; the body has exactly the recorded
10
+ * keys; the id is any JSON-RPC id of the accepted form where one is recorded; and `params` equal
11
+ * the recorded ones except for JSON key order and the `_meta` client info's name and version
12
+ * (and, on the second page of the paged listing, the cursor, checked by issuance). Everything
13
+ * else is refused with a constant reason. The answer is the recorded one with only the request id
14
+ * substituted where the recording carries the recorded request id (the top-level `id` of a JSON
15
+ * answer, or of the SSE response event's payload), the session id (minted, or the request's) in
16
+ * the `mcp-session-id` header, and the cursor of the current generation in `nextCursor`.
17
+ *
18
+ * @experimental
19
+ */
20
+ import { Predicate } from 'effect'
21
+ import type * as Schema from 'effect/Schema'
22
+ import type { EmulatorRouteEvidence } from '../route-evidence.ts'
23
+ import {
24
+ isJsonObject,
25
+ isNotEmulated,
26
+ notEmulated,
27
+ parseJsonText,
28
+ streamedCommit,
29
+ type Admission,
30
+ type EmulatedRequest,
31
+ type NotEmulated,
32
+ type Planned,
33
+ type StatefulRoute,
34
+ type StreamedAnswer
35
+ } from '../stateful-emulator.ts'
36
+ import {
37
+ mcpEmulatorFixtures,
38
+ type McpRecordedExchange,
39
+ type McpRecordedFixture,
40
+ type McpRecordedRequest,
41
+ type McpRecordedResponse
42
+ } from './recordings.ts'
43
+ import {
44
+ mcpEmulatorLegacyPath,
45
+ mcpEmulatorModernPath,
46
+ mcpEmulatorOrigin,
47
+ mcpEmulatorSessionCap,
48
+ mcpEmulatorSessionPrefix,
49
+ type McpEmulatorState,
50
+ type McpRuntime
51
+ } from './state.ts'
52
+
53
+ /** Drill knobs (tests only): each makes the emulator disagree with exactly one case's claim. */
54
+ export type McpEmulatorDrills = {
55
+ /** `mcp.negotiation.era` (modern): the discover answer also carries a JSON-RPC error. */
56
+ readonly discoverCarriesErrorResponse?: boolean
57
+ /** `mcp.modern.stateless`: the modern discover result lacks `resultType`. */
58
+ readonly discoverWithoutResultType?: boolean
59
+ /** `mcp.legacy.session`: minted session ids hold a space (not visible ASCII). */
60
+ readonly sessionIdNotVisibleAscii?: boolean
61
+ /** `mcp.transport.response-encoding` (modern): the discover answer holds its response twice. */
62
+ readonly discoverAnsweredTwice?: boolean
63
+ /** `mcp.tools.list`: the listing marks the write tool `readOnlyHint: true`. */
64
+ readonly writeToolMarkedReadOnly?: boolean
65
+ /** `mcp.tools.call-read`: the read call answers `isError: true`. */
66
+ readonly readCallAnswersToolError?: boolean
67
+ /** `mcp.tools.call-tool-error`: invalid arguments answer a JSON-RPC error. */
68
+ readonly invalidCallAnswersRpcError?: boolean
69
+ /** `mcp.errors.unknown-tool`: the absent tool answers a tool result. */
70
+ readonly absentCallAnswersResult?: boolean
71
+ /** `mcp.auth.rejected`: the 401 carries no `WWW-Authenticate` challenge. */
72
+ readonly unauthorizedWithoutChallenge?: boolean
73
+ }
74
+
75
+ export const mcpEmulatorDrillKnobs: ReadonlyArray<keyof McpEmulatorDrills> = [
76
+ 'discoverCarriesErrorResponse',
77
+ 'discoverWithoutResultType',
78
+ 'sessionIdNotVisibleAscii',
79
+ 'discoverAnsweredTwice',
80
+ 'writeToolMarkedReadOnly',
81
+ 'readCallAnswersToolError',
82
+ 'invalidCallAnswersRpcError',
83
+ 'absentCallAnswersResult',
84
+ 'unauthorizedWithoutChallenge'
85
+ ]
86
+
87
+ export type McpApiEnv = {
88
+ readonly drills: Readonly<Record<keyof McpEmulatorDrills, boolean>>
89
+ /** The digest of the reserved invalid credential on the emulated origin. */
90
+ readonly reservedDigest: string
91
+ /** Runtime data the commits advance (never in the state). */
92
+ readonly runtime: McpRuntime
93
+ }
94
+
95
+ type McpRoute = StatefulRoute<McpEmulatorState, McpApiEnv>
96
+
97
+ type RpcId = string | number
98
+
99
+ type JsonObject = Schema.JsonObject
100
+
101
+ const fieldOf = (value: Schema.Json | undefined, key: string): Schema.Json | undefined =>
102
+ isJsonObject(value) ? value[key] : undefined
103
+
104
+ // The recorded exchanges the routes answer, by fixture id and exchange index.
105
+
106
+ const exchangeOf = (fixtureId: string, index: number): McpRecordedExchange => {
107
+ const exchange = mcpEmulatorFixtures
108
+ .find(fixture => fixture.id === fixtureId)
109
+ ?.exchanges.at(index)
110
+
111
+ if (exchange === undefined) {
112
+ throw new Error(`the MCP recordings have no exchange ${index} in ${fixtureId}`)
113
+ }
114
+
115
+ return exchange
116
+ }
117
+
118
+ const modern = (caseId: string) => `${caseId}.modern.synthetic`
119
+
120
+ const legacy = (caseId: string) => `${caseId}.legacy.synthetic`
121
+
122
+ const recorded = {
123
+ modernDiscover: exchangeOf(modern('mcp.negotiation.era'), 0),
124
+ modernRejected: exchangeOf(modern('mcp.auth.rejected'), 0),
125
+ modernList: exchangeOf(modern('mcp.negotiation.era'), 1),
126
+ modernFirstPage: exchangeOf(modern('mcp.tools.list'), 1),
127
+ modernSecondPage: exchangeOf(modern('mcp.tools.list'), 2),
128
+ modernCallRead: exchangeOf(modern('mcp.tools.call-read'), -1),
129
+ modernCallToolError: exchangeOf(modern('mcp.tools.call-tool-error'), -1),
130
+ modernCallAbsent: exchangeOf(modern('mcp.errors.unknown-tool'), -1),
131
+ legacyDiscover: exchangeOf(legacy('mcp.negotiation.era'), 0),
132
+ legacyRejected: exchangeOf(legacy('mcp.auth.rejected'), 0),
133
+ legacyInitialize: exchangeOf(legacy('mcp.negotiation.era'), 1),
134
+ legacyInitialized: exchangeOf(legacy('mcp.negotiation.era'), 2),
135
+ legacyGet: exchangeOf(legacy('mcp.negotiation.era'), 3),
136
+ legacyList: exchangeOf(legacy('mcp.negotiation.era'), 4),
137
+ legacyCallRead: exchangeOf(legacy('mcp.tools.call-read'), -1),
138
+ legacyCallToolError: exchangeOf(legacy('mcp.tools.call-tool-error'), -1),
139
+ legacyCallAbsent: exchangeOf(legacy('mcp.errors.unknown-tool'), -1)
140
+ }
141
+
142
+ type RecordedKey = keyof typeof recorded
143
+
144
+ /** The cursor the paged listing records between its two pages. */
145
+ const recordedCursor = (() => {
146
+ const cursor = fieldOf(fieldOf(recorded.modernSecondPage.request.body, 'params'), 'cursor')
147
+
148
+ if (!Predicate.isString(cursor)) {
149
+ throw new Error('the paged MCP listing records no cursor')
150
+ }
151
+
152
+ return cursor
153
+ })()
154
+
155
+ // The manifest: one row per recorded JSON-RPC method of each profile, plus the GET row.
156
+
157
+ const casesSending = (path: string, sends: (request: McpRecordedRequest) => boolean) => [
158
+ ...new Set(
159
+ mcpEmulatorFixtures.flatMap(fixture =>
160
+ fixture.endpoint === `${mcpEmulatorOrigin}${path}` &&
161
+ fixture.exchanges.some(exchange => sends(exchange.request))
162
+ ? [fixture.caseId]
163
+ : []
164
+ )
165
+ )
166
+ ]
167
+
168
+ const rpcMethodOf = (request: McpRecordedRequest): string | undefined => {
169
+ const method = fieldOf(request.body, 'method')
170
+
171
+ return Predicate.isString(method) ? method : undefined
172
+ }
173
+
174
+ const rpcRow = (path: string, method: string): EmulatorRouteEvidence => ({
175
+ method: 'RPC',
176
+ path: `${mcpEmulatorOrigin}${path}#${method}`,
177
+ kind: 'connector',
178
+ write: false,
179
+ caseIds: casesSending(
180
+ path,
181
+ request => request.method === 'POST' && rpcMethodOf(request) === method
182
+ ),
183
+ evidence: 'unverified'
184
+ })
185
+
186
+ const modernMethods = ['server/discover', 'tools/list', 'tools/call'] as const
187
+
188
+ const legacyMethods = [
189
+ 'server/discover',
190
+ 'initialize',
191
+ 'notifications/initialized',
192
+ 'tools/list',
193
+ 'tools/call'
194
+ ] as const
195
+
196
+ const modernRows = modernMethods.map(method => rpcRow(mcpEmulatorModernPath, method))
197
+
198
+ const legacyRows = legacyMethods.map(method => rpcRow(mcpEmulatorLegacyPath, method))
199
+
200
+ const getRow: EmulatorRouteEvidence = {
201
+ method: 'GET',
202
+ path: `${mcpEmulatorOrigin}${mcpEmulatorLegacyPath}`,
203
+ kind: 'connector',
204
+ write: false,
205
+ caseIds: casesSending(mcpEmulatorLegacyPath, request => request.method === 'GET'),
206
+ evidence: 'unverified'
207
+ }
208
+
209
+ const rowOf = (path: string, method: string): string => `${mcpEmulatorOrigin}${path}#${method}`
210
+
211
+ // Answers: the recorded bytes, with only the request id, session id, and cursor substituted.
212
+
213
+ const isEventStream = (headers: Readonly<Record<string, string>>): boolean =>
214
+ headers['content-type'] === 'text/event-stream'
215
+
216
+ /** A recorded answer as status, headers, and chunks (a JSON body is one chunk; none is none). */
217
+ const answerOf = (response: McpRecordedResponse): StreamedAnswer => ({
218
+ status: response.status,
219
+ headers: response.headers,
220
+ chunks: 'chunks' in response ? response.chunks : response.body === '' ? [] : [response.body]
221
+ })
222
+
223
+ const isResponseTo = (message: Schema.Json | undefined, id: RpcId): message is JsonObject =>
224
+ isJsonObject(message) && message['id'] === id && ('result' in message || 'error' in message)
225
+
226
+ const dataPrefix = 'data: '
227
+
228
+ /**
229
+ * `answer` with the JSON-RPC response to `recordedId` (the top-level message of a JSON answer, or
230
+ * the payload of the SSE event that carries it) replaced by `change` of it; every other chunk,
231
+ * line, and event (notifications, `id:` lines) stays byte for byte.
232
+ */
233
+ const mapResponse = (
234
+ answer: StreamedAnswer,
235
+ recordedId: RpcId,
236
+ change: (message: JsonObject) => Schema.Json
237
+ ): StreamedAnswer => {
238
+ const eventStream = isEventStream(answer.headers)
239
+
240
+ const chunks = answer.chunks.map(chunk => {
241
+ if (!eventStream) {
242
+ const message = parseJsonText(chunk)
243
+
244
+ return isResponseTo(message, recordedId) ? JSON.stringify(change(message)) : chunk
245
+ }
246
+
247
+ return chunk
248
+ .split('\n')
249
+ .map(line => {
250
+ if (!line.startsWith(dataPrefix)) return line
251
+
252
+ const message = parseJsonText(line.slice(dataPrefix.length))
253
+
254
+ return isResponseTo(message, recordedId)
255
+ ? `${dataPrefix}${JSON.stringify(change(message))}`
256
+ : line
257
+ })
258
+ .join('\n')
259
+ })
260
+
261
+ return { ...answer, chunks }
262
+ }
263
+
264
+ const recordedIdOf = (exchange: McpRecordedExchange): RpcId | undefined => {
265
+ const id = fieldOf(exchange.request.body, 'id')
266
+
267
+ return Predicate.isString(id) || Predicate.isNumber(id) ? id : undefined
268
+ }
269
+
270
+ /** The recorded answer of `exchange` with the request's id where it carries the recorded id. */
271
+ const answerWithId = (exchange: McpRecordedExchange, id: RpcId | undefined): StreamedAnswer => {
272
+ const answer = answerOf(exchange.response)
273
+ const recordedId = recordedIdOf(exchange)
274
+
275
+ // The recorded request id stays byte for byte; so does an answer that does not carry it.
276
+ if (recordedId === undefined || id === undefined || id === recordedId) return answer
277
+
278
+ return mapResponse(answer, recordedId, message => ({ ...message, id }))
279
+ }
280
+
281
+ /** `answer` with its `mcp-session-id` header (recorded on SSE answers) set to `session`. */
282
+ const withSession = (answer: StreamedAnswer, session: string): StreamedAnswer =>
283
+ answer.headers['mcp-session-id'] === undefined
284
+ ? answer
285
+ : { ...answer, headers: { ...answer.headers, 'mcp-session-id': session } }
286
+
287
+ /** The response message of an answer to `id` with `change` applied to its `result`. */
288
+ const mapResult =
289
+ (change: (result: JsonObject) => JsonObject) =>
290
+ (message: JsonObject): JsonObject => {
291
+ const result = message['result']
292
+
293
+ return isJsonObject(result) ? { ...message, result: change(result) } : message
294
+ }
295
+
296
+ /**
297
+ * Where a copied recording is not in the canonical form id substitution relies on: every JSON
298
+ * answer body, and every SSE `data:` payload, must equal `JSON.stringify(JSON.parse(text))`, and
299
+ * every SSE data line must start with `data: `. `makeMcpEmulator` throws on any problem, so a
300
+ * recording copied in another form (escapes such as `\u00e9` or `\/`, numbers such as `1.0`,
301
+ * spacing) fails loudly instead of answering bytes the substitution would rewrite.
302
+ */
303
+ export const mcpRecordingProblems = (
304
+ fixtures: ReadonlyArray<McpRecordedFixture>
305
+ ): ReadonlyArray<string> =>
306
+ fixtures.flatMap(fixture =>
307
+ fixture.exchanges.flatMap(({ response }, index) => {
308
+ const where = `${fixture.id} exchange ${index}`
309
+
310
+ const canonical = (text: string) => {
311
+ const parsed = parseJsonText(text)
312
+
313
+ return parsed !== undefined && JSON.stringify(parsed) === text
314
+ }
315
+
316
+ if (!('chunks' in response)) {
317
+ return response.body === '' || canonical(response.body)
318
+ ? []
319
+ : [`${where}: the JSON body is not canonical`]
320
+ }
321
+
322
+ return response.chunks.flatMap((chunk, at) =>
323
+ chunk
324
+ .split('\n')
325
+ .flatMap(line =>
326
+ !line.startsWith('data') ||
327
+ (line.startsWith(dataPrefix) && canonical(line.slice(dataPrefix.length)))
328
+ ? []
329
+ : [`${where} chunk ${at}: an SSE data line is not canonical`]
330
+ )
331
+ )
332
+ })
333
+ )
334
+
335
+ // Request checks: constant reasons only (the wrapper ledgers refusals as constant entries).
336
+
337
+ /** The MCP headers compared with the recording (absent where the recording has none). */
338
+ const comparedHeaders = [
339
+ 'accept',
340
+ 'content-type',
341
+ 'last-event-id',
342
+ 'mcp-method',
343
+ 'mcp-name',
344
+ 'mcp-protocol-version'
345
+ ] as const
346
+
347
+ const sessionHeader = 'mcp-session-id'
348
+
349
+ /** The `mcp-*` headers a recording carries; any other (such as `mcp-param-*`) is refused. */
350
+ const recordedMcpHeaders: ReadonlySet<string> = new Set([
351
+ 'mcp-method',
352
+ 'mcp-name',
353
+ 'mcp-protocol-version',
354
+ sessionHeader
355
+ ])
356
+
357
+ /** Refuse an `mcp-*` header no recording carries (fail closed when the names are unknown). */
358
+ const unrecordedMcpHeader = (request: EmulatedRequest): NotEmulated | undefined =>
359
+ request.headerNames === undefined ||
360
+ request.headerNames.some(name => name.startsWith('mcp-') && !recordedMcpHeaders.has(name))
361
+ ? notEmulated('an mcp-* header no recording carries is not emulated')
362
+ : undefined
363
+
364
+ const headerProblem = (
365
+ request: EmulatedRequest,
366
+ recordedRequest: McpRecordedRequest,
367
+ names: ReadonlyArray<string>
368
+ ): NotEmulated | undefined => {
369
+ for (const name of names) {
370
+ if (request.header(name) !== recordedRequest.headers[name]) {
371
+ return notEmulated(`the ${name} header must be the recorded value, or absent where none is`)
372
+ }
373
+ }
374
+
375
+ if (
376
+ (request.header(sessionHeader) === undefined) !==
377
+ (recordedRequest.headers[sessionHeader] === undefined)
378
+ ) {
379
+ return notEmulated('mcp-session-id must be sent exactly where the recording sends one')
380
+ }
381
+
382
+ return undefined
383
+ }
384
+
385
+ /**
386
+ * A JSON-RPC request id of the emulated form: an integer from 0 to 2^53 - 1, or 1 to 64 printable
387
+ * ASCII characters.
388
+ */
389
+ export const isMcpEmulatedRequestId = (id: Schema.Json | undefined): id is RpcId =>
390
+ (Predicate.isNumber(id) && Number.isSafeInteger(id) && id >= 0) ||
391
+ (Predicate.isString(id) && /^[\x20-\x7E]{1,64}$/.test(id))
392
+
393
+ /** Structural JSON equality, ignoring object key order. */
394
+ const jsonEqual = (left: Schema.Json, right: Schema.Json): boolean => {
395
+ if (Array.isArray(left)) {
396
+ return (
397
+ Array.isArray(right) &&
398
+ left.length === right.length &&
399
+ left.every((item, index) => {
400
+ const other = right[index]
401
+
402
+ return other !== undefined && jsonEqual(item, other)
403
+ })
404
+ )
405
+ }
406
+
407
+ if (isJsonObject(left)) {
408
+ if (!isJsonObject(right)) return false
409
+
410
+ const keys = Object.keys(left)
411
+
412
+ return (
413
+ keys.length === Object.keys(right).length &&
414
+ keys.every(key => {
415
+ const value = left[key]
416
+ const other = right[key]
417
+
418
+ return (
419
+ Object.hasOwn(right, key) &&
420
+ value !== undefined &&
421
+ other !== undefined &&
422
+ jsonEqual(value, other)
423
+ )
424
+ })
425
+ )
426
+ }
427
+
428
+ return left === right
429
+ }
430
+
431
+ const clientInfoKey = 'io.modelcontextprotocol/clientInfo'
432
+
433
+ const isClientInfo = (value: Schema.Json | undefined): boolean =>
434
+ isJsonObject(value) &&
435
+ Object.keys(value).length === 2 &&
436
+ Predicate.isString(value['name']) &&
437
+ value['name'].length > 0 &&
438
+ Predicate.isString(value['version']) &&
439
+ value['version'].length > 0
440
+
441
+ /**
442
+ * The request params normalised for comparison with `recordedParams`: the `_meta` client info
443
+ * replaced by the recorded one (when it is any non-empty name and version), and with `cursorFree`
444
+ * the cursor replaced by the recorded one (when it is a string; checked by issuance at plan).
445
+ */
446
+ const normalisedParams = (
447
+ params: Schema.Json | undefined,
448
+ recordedParams: JsonObject,
449
+ cursorFree: boolean
450
+ ): JsonObject | NotEmulated => {
451
+ if (!isJsonObject(params)) return notEmulated('params must be the recorded JSON object')
452
+
453
+ const meta = params['_meta']
454
+ const recordedMeta = recordedParams['_meta']
455
+ let normalised: JsonObject = params
456
+
457
+ if (isJsonObject(recordedMeta) && recordedMeta[clientInfoKey] !== undefined) {
458
+ if (!isJsonObject(meta) || !isClientInfo(meta[clientInfoKey])) {
459
+ return notEmulated('the _meta client info must be a non-empty name and version only')
460
+ }
461
+
462
+ normalised = { ...normalised, _meta: { ...meta, [clientInfoKey]: recordedMeta[clientInfoKey] } }
463
+ }
464
+
465
+ if (cursorFree) {
466
+ if (!Predicate.isString(params['cursor'])) {
467
+ return notEmulated('the second page must send the cursor the first page issued')
468
+ }
469
+
470
+ normalised = { ...normalised, cursor: recordedCursor }
471
+ }
472
+
473
+ return normalised
474
+ }
475
+
476
+ type Matched = {
477
+ readonly key: RecordedKey
478
+ readonly id: RpcId | undefined
479
+ readonly cursor: string | undefined
480
+ }
481
+
482
+ /**
483
+ * The first candidate whose recorded request the JSON-RPC POST equals within the latitude, or
484
+ * not emulated. Every candidate records the same method; candidates differ only in their params
485
+ * and `mcp-name` (the recorded tool calls).
486
+ */
487
+ const matchRpc = (
488
+ request: EmulatedRequest,
489
+ candidates: ReadonlyArray<RecordedKey>,
490
+ options: { readonly cursorFree?: boolean } = {}
491
+ ): Matched | NotEmulated => {
492
+ const [first] = candidates
493
+
494
+ if (first === undefined) return notEmulated('this JSON-RPC method is not emulated here')
495
+
496
+ const firstRequest = recorded[first].request
497
+ const body = request.json
498
+ const recordedBody = firstRequest.body
499
+
500
+ if (!isJsonObject(recordedBody)) return notEmulated('this JSON-RPC method is not emulated here')
501
+
502
+ const common =
503
+ unrecordedMcpHeader(request) ??
504
+ headerProblem(
505
+ request,
506
+ firstRequest,
507
+ comparedHeaders.filter(name => name !== 'mcp-name')
508
+ )
509
+
510
+ if (common !== undefined) return common
511
+
512
+ if (!isJsonObject(body)) {
513
+ return notEmulated('JSON-RPC batches and non-object bodies are not emulated')
514
+ }
515
+
516
+ const keys = Object.keys(body)
517
+ const recordedKeys = Object.keys(recordedBody)
518
+
519
+ if (keys.length !== recordedKeys.length || keys.some(key => !recordedKeys.includes(key))) {
520
+ return notEmulated('the JSON-RPC message must have exactly the recorded members')
521
+ }
522
+
523
+ if (body['jsonrpc'] !== '2.0') return notEmulated('jsonrpc must be "2.0"')
524
+
525
+ const id = body['id']
526
+
527
+ if ('id' in recordedBody && !isMcpEmulatedRequestId(id)) {
528
+ return notEmulated(
529
+ 'the request id must be an integer from 0 to 2^53 - 1 or 1 to 64 printable ASCII characters'
530
+ )
531
+ }
532
+
533
+ const cursor = options.cursorFree === true ? fieldOf(body['params'], 'cursor') : undefined
534
+
535
+ for (const key of candidates) {
536
+ const candidate = recorded[key].request
537
+ const candidateBody = candidate.body
538
+
539
+ if (!isJsonObject(candidateBody)) continue
540
+
541
+ if (headerProblem(request, candidate, ['mcp-name']) !== undefined) continue
542
+
543
+ const recordedParams = candidateBody['params']
544
+
545
+ if (recordedParams !== undefined) {
546
+ const params = isJsonObject(recordedParams)
547
+ ? normalisedParams(body['params'], recordedParams, options.cursorFree === true)
548
+ : notEmulated('params must be the recorded value')
549
+
550
+ if (isNotEmulated(params)) return params
551
+
552
+ if (!jsonEqual(params, recordedParams)) continue
553
+ }
554
+
555
+ return {
556
+ key,
557
+ id: isMcpEmulatedRequestId(id) ? id : undefined,
558
+ cursor: Predicate.isString(cursor) ? cursor : undefined
559
+ }
560
+ }
561
+
562
+ return notEmulated('params other than the recorded ones are not emulated')
563
+ }
564
+
565
+ /** Refuse any query parameter (no fixture records one). */
566
+ const noQuery = (request: EmulatedRequest): NotEmulated | undefined =>
567
+ request.rawQuery === undefined || request.rawQuery === ''
568
+ ? undefined
569
+ : notEmulated('query parameters are not emulated')
570
+
571
+ const isReserved = (request: EmulatedRequest, env: McpApiEnv): boolean =>
572
+ request.bearerDigest === env.reservedDigest
573
+
574
+ const reservedOnlyOnProbe = 'the reserved invalid credential is answered only on the era probe'
575
+
576
+ // Plans and commits.
577
+
578
+ type Admitted = Matched & {
579
+ /** The request's `mcp-session-id` (legacy non-handshake requests). */
580
+ readonly session: string | undefined
581
+ }
582
+
583
+ type Plan = (state: McpEmulatorState, admitted: Admitted, env: McpApiEnv) => Planned
584
+
585
+ const admission = (
586
+ variant: string,
587
+ admitted: Admitted,
588
+ plan: Plan
589
+ ): Admission<McpEmulatorState, McpApiEnv> => ({
590
+ variant,
591
+ plan: (state, context) => plan(state, admitted, context.env)
592
+ })
593
+
594
+ /** A commit that answers `answer` and writes nothing. */
595
+ const answerOnly = (answer: StreamedAnswer): Planned => streamedCommit(answer)
596
+
597
+ const sessionOf = (state: McpEmulatorState, admitted: Admitted, phase: 'initializing' | 'ready') =>
598
+ state.sessions.find(session => session.id === admitted.session && session.phase === phase)
599
+
600
+ const unknownSession = (phase: 'initializing' | 'ready') =>
601
+ notEmulated(
602
+ phase === 'ready'
603
+ ? 'a request on no ready session of this emulator is not emulated'
604
+ : 'notifications/initialized on no initializing session of this emulator is not emulated'
605
+ )
606
+
607
+ /** The JSON-RPC error the era drill adds to the discover answer (another id: the SDK skips it). */
608
+ const drillErrorResponse = JSON.stringify({
609
+ jsonrpc: '2.0',
610
+ id: 'yolk-emu-drill',
611
+ error: { code: -32_603, message: 'Synthetic drill error.' }
612
+ })
613
+
614
+ /** Drill transforms of an answer, by the recorded exchange it answers. */
615
+ const drilled = (key: RecordedKey, answer: StreamedAnswer, id: RpcId, env: McpApiEnv) => {
616
+ const drills = env.drills
617
+
618
+ switch (key) {
619
+ case 'modernDiscover': {
620
+ let next = answer
621
+
622
+ if (drills.discoverWithoutResultType) {
623
+ next = mapResponse(
624
+ next,
625
+ id,
626
+ mapResult(({ resultType: _resultType, ...result }) => result)
627
+ )
628
+ }
629
+
630
+ if (drills.discoverCarriesErrorResponse) {
631
+ next = {
632
+ ...next,
633
+ chunks: next.chunks.map(chunk => `[${drillErrorResponse},${chunk}]`)
634
+ }
635
+ }
636
+
637
+ if (drills.discoverAnsweredTwice) {
638
+ next = {
639
+ ...next,
640
+ headers: { ...next.headers, 'content-type': 'text/event-stream' },
641
+ chunks: next.chunks.flatMap(chunk => [
642
+ `event: message\ndata: ${chunk}\n\n`,
643
+ `event: message\ndata: ${chunk}\n\n`
644
+ ])
645
+ }
646
+ }
647
+
648
+ return next
649
+ }
650
+
651
+ case 'modernList':
652
+ case 'modernFirstPage':
653
+ case 'modernSecondPage':
654
+ case 'legacyList':
655
+ return drills.writeToolMarkedReadOnly
656
+ ? mapResponse(
657
+ answer,
658
+ id,
659
+ mapResult(result => {
660
+ const tools = result['tools']
661
+
662
+ return Array.isArray(tools)
663
+ ? {
664
+ ...result,
665
+ tools: tools.map(tool =>
666
+ isJsonObject(tool) && tool['name'] === 'create_synthetic_note'
667
+ ? { ...tool, annotations: { readOnlyHint: true } }
668
+ : tool
669
+ )
670
+ }
671
+ : result
672
+ })
673
+ )
674
+ : answer
675
+
676
+ case 'modernCallRead':
677
+ case 'legacyCallRead':
678
+ return drills.readCallAnswersToolError
679
+ ? mapResponse(
680
+ answer,
681
+ id,
682
+ mapResult(result => ({ ...result, isError: true }))
683
+ )
684
+ : answer
685
+
686
+ case 'modernCallToolError':
687
+ case 'legacyCallToolError':
688
+ return drills.invalidCallAnswersRpcError
689
+ ? mapResponse(answer, id, () => ({
690
+ jsonrpc: '2.0',
691
+ id,
692
+ error: { code: -32_602, message: 'Invalid arguments: noteId must be a string.' }
693
+ }))
694
+ : answer
695
+
696
+ case 'modernCallAbsent':
697
+ case 'legacyCallAbsent': {
698
+ if (!drills.absentCallAnswersResult) return answer
699
+
700
+ const result: JsonObject = {
701
+ content: [{ type: 'text', text: 'Synthetic drill result.' }],
702
+ isError: true
703
+ }
704
+
705
+ return mapResponse({ ...answer, status: 200 }, id, () => ({
706
+ jsonrpc: '2.0',
707
+ id,
708
+ result: key === 'modernCallAbsent' ? { ...result, resultType: 'complete' } : result
709
+ }))
710
+ }
711
+
712
+ case 'modernRejected':
713
+ case 'legacyRejected': {
714
+ if (!drills.unauthorizedWithoutChallenge) return answer
715
+
716
+ const { 'www-authenticate': _challenge, ...headers } = answer.headers
717
+
718
+ return { ...answer, headers }
719
+ }
720
+
721
+ default:
722
+ return answer
723
+ }
724
+ }
725
+
726
+ /** The id the answer's response carries: the request's (substituted), else the recorded one. */
727
+ const responseIdOf = (key: RecordedKey, id: RpcId | undefined): RpcId | undefined =>
728
+ id ?? recordedIdOf(recorded[key])
729
+
730
+ /** The recorded answer for `key`, with the request id substituted and any drill applied. */
731
+ const recordedAnswer = (key: RecordedKey, id: RpcId | undefined, env: McpApiEnv) => {
732
+ const answer = answerWithId(recorded[key], id)
733
+ const responseId = responseIdOf(key, id)
734
+
735
+ return responseId === undefined ? answer : drilled(key, answer, responseId, env)
736
+ }
737
+
738
+ /** The cursor value issued in the current generation (the recorded one in its first). */
739
+ const cursorFor = (runtime: McpRuntime): string =>
740
+ (runtime.cursorFirstGeneration ?? runtime.generation) === runtime.generation
741
+ ? recordedCursor
742
+ : `${recordedCursor}.g${runtime.generation}`
743
+
744
+ const planModernList: Plan = (state, admitted, env) => {
745
+ if (state.modernListing === 'one-page') {
746
+ return answerOnly(recordedAnswer('modernList', admitted.id, env))
747
+ }
748
+
749
+ const cursor = cursorFor(env.runtime)
750
+ const recordedPage = recordedAnswer('modernFirstPage', admitted.id, env)
751
+ const responseId = responseIdOf('modernFirstPage', admitted.id)
752
+
753
+ // The recorded bytes in the generation that first issues the cursor; a minted cursor after it.
754
+ const sent =
755
+ cursor === recordedCursor || responseId === undefined
756
+ ? recordedPage
757
+ : mapResponse(
758
+ recordedPage,
759
+ responseId,
760
+ mapResult(result => ({ ...result, nextCursor: cursor }))
761
+ )
762
+
763
+ // The cursor is issued only by the commit, so a faulted or truncated first page issues none.
764
+ return streamedCommit(sent, () => {
765
+ env.runtime.cursorFirstGeneration ??= env.runtime.generation
766
+ env.runtime.issuedCursor = cursor
767
+ }, [cursor])
768
+ }
769
+
770
+ const planModernSecondPage: Plan = (state, admitted, env) =>
771
+ state.modernListing === 'two-pages' &&
772
+ env.runtime.issuedCursor !== undefined &&
773
+ admitted.cursor === env.runtime.issuedCursor
774
+ ? answerOnly(recordedAnswer('modernSecondPage', admitted.id, env))
775
+ : notEmulated(
776
+ 'a cursor this emulator did not issue since the last reset or seed is not emulated'
777
+ )
778
+
779
+ const planInitialize: Plan = (state, admitted, env) => {
780
+ const number = env.runtime.nextSession
781
+
782
+ if (state.sessions.length >= mcpEmulatorSessionCap || !Number.isSafeInteger(number + 1)) {
783
+ return notEmulated('the emulator holds as many sessions as it takes')
784
+ }
785
+
786
+ const session = env.drills.sessionIdNotVisibleAscii
787
+ ? `yolk-emu-session ${number}`
788
+ : `${mcpEmulatorSessionPrefix}${number}`
789
+
790
+ const answer = withSession(recordedAnswer('legacyInitialize', admitted.id, env), session)
791
+
792
+ // The session is minted only by the commit, so a faulted or truncated `initialize` holds none
793
+ // and leaves the counter where it was.
794
+ return streamedCommit(answer, () => {
795
+ env.runtime.nextSession = number + 1
796
+ state.sessions = [...state.sessions, { id: session, phase: 'initializing' }]
797
+ }, [session])
798
+ }
799
+
800
+ const planInitialized: Plan = (state, admitted, env) => {
801
+ const session = sessionOf(state, admitted, 'initializing')
802
+
803
+ if (session === undefined) return unknownSession('initializing')
804
+
805
+ const answer = recordedAnswer('legacyInitialized', undefined, env)
806
+
807
+ return streamedCommit(answer, () => {
808
+ state.sessions = state.sessions.map(held =>
809
+ held.id === session.id ? { id: held.id, phase: 'ready' } : held
810
+ )
811
+ })
812
+ }
813
+
814
+ /** A legacy answer on a ready session: the recorded answer with the session's id. */
815
+ const planOnSession =
816
+ (key: RecordedKey): Plan =>
817
+ (state, admitted, env) => {
818
+ const session = sessionOf(state, admitted, 'ready')
819
+
820
+ return session === undefined
821
+ ? unknownSession('ready')
822
+ : answerOnly(withSession(recordedAnswer(key, admitted.id, env), session.id))
823
+ }
824
+
825
+ const planAnswer =
826
+ (key: RecordedKey): Plan =>
827
+ (_state, admitted, env) =>
828
+ answerOnly(recordedAnswer(key, admitted.id, env))
829
+
830
+ // Routes.
831
+
832
+ const routeEvidenceOf = (
833
+ method: string,
834
+ path: string,
835
+ variants: ReadonlyArray<EmulatorRouteEvidence>
836
+ ) => ({
837
+ method,
838
+ path,
839
+ kind: 'connector' as const,
840
+ write: false,
841
+ caseIds: [...new Set(variants.flatMap(variant => variant.caseIds))],
842
+ evidence: 'unverified' as const,
843
+ origin: mcpEmulatorOrigin,
844
+ variants
845
+ })
846
+
847
+ const admitted = (matched: Matched, request: EmulatedRequest): Admitted => ({
848
+ ...matched,
849
+ session: request.header(sessionHeader)
850
+ })
851
+
852
+ /** `POST /modern/mcp`: the stateless synthetic server (JSON answers, no session). */
853
+ const modernRoute: McpRoute = {
854
+ ...routeEvidenceOf('POST', mcpEmulatorModernPath, modernRows),
855
+ body: 'json',
856
+ admit: (request, env) => {
857
+ const query = noQuery(request)
858
+
859
+ if (query !== undefined) return query
860
+
861
+ const method = fieldOf(request.json, 'method')
862
+ const variant = (name: string) => rowOf(mcpEmulatorModernPath, name)
863
+
864
+ if (method === 'server/discover') {
865
+ const matched = matchRpc(request, ['modernDiscover'])
866
+
867
+ if (isNotEmulated(matched)) return matched
868
+
869
+ return isReserved(request, env)
870
+ ? admission(variant(method), admitted(matched, request), planAnswer('modernRejected'))
871
+ : admission(variant(method), admitted(matched, request), planAnswer('modernDiscover'))
872
+ }
873
+
874
+ if (isReserved(request, env)) return notEmulated(reservedOnlyOnProbe)
875
+
876
+ if (method === 'tools/list') {
877
+ const paged = fieldOf(fieldOf(request.json, 'params'), 'cursor') !== undefined
878
+
879
+ const matched = paged
880
+ ? matchRpc(request, ['modernSecondPage'], { cursorFree: true })
881
+ : matchRpc(request, ['modernList'])
882
+
883
+ if (isNotEmulated(matched)) return matched
884
+
885
+ return admission(
886
+ variant(method),
887
+ admitted(matched, request),
888
+ paged ? planModernSecondPage : planModernList
889
+ )
890
+ }
891
+
892
+ if (method === 'tools/call') {
893
+ const matched = matchRpc(request, [
894
+ 'modernCallRead',
895
+ 'modernCallToolError',
896
+ 'modernCallAbsent'
897
+ ])
898
+
899
+ if (isNotEmulated(matched)) return matched
900
+
901
+ return admission(variant(method), admitted(matched, request), planAnswer(matched.key))
902
+ }
903
+
904
+ return notEmulated('this JSON-RPC method is not emulated on the modern profile')
905
+ }
906
+ }
907
+
908
+ /** `POST /legacy/mcp`: the initialize-based synthetic server (SSE answers, sessions). */
909
+ const legacyRoute: McpRoute = {
910
+ ...routeEvidenceOf('POST', mcpEmulatorLegacyPath, legacyRows),
911
+ body: 'json',
912
+ admit: (request, env) => {
913
+ const query = noQuery(request)
914
+
915
+ if (query !== undefined) return query
916
+
917
+ const method = fieldOf(request.json, 'method')
918
+ const variant = (name: string) => rowOf(mcpEmulatorLegacyPath, name)
919
+
920
+ if (method === 'server/discover') {
921
+ const matched = matchRpc(request, ['legacyDiscover'])
922
+
923
+ if (isNotEmulated(matched)) return matched
924
+
925
+ return isReserved(request, env)
926
+ ? admission(variant(method), admitted(matched, request), planAnswer('legacyRejected'))
927
+ : admission(variant(method), admitted(matched, request), planAnswer('legacyDiscover'))
928
+ }
929
+
930
+ if (isReserved(request, env)) return notEmulated(reservedOnlyOnProbe)
931
+
932
+ const single = (key: RecordedKey, name: string, plan: Plan) => {
933
+ const matched = matchRpc(request, [key])
934
+
935
+ return isNotEmulated(matched)
936
+ ? matched
937
+ : admission(variant(name), admitted(matched, request), plan)
938
+ }
939
+
940
+ if (method === 'initialize') return single('legacyInitialize', method, planInitialize)
941
+
942
+ if (method === 'notifications/initialized') {
943
+ return single('legacyInitialized', method, planInitialized)
944
+ }
945
+
946
+ if (method === 'tools/list') return single('legacyList', method, planOnSession('legacyList'))
947
+
948
+ if (method === 'tools/call') {
949
+ const matched = matchRpc(request, [
950
+ 'legacyCallRead',
951
+ 'legacyCallToolError',
952
+ 'legacyCallAbsent'
953
+ ])
954
+
955
+ if (isNotEmulated(matched)) return matched
956
+
957
+ return admission(variant(method), admitted(matched, request), planOnSession(matched.key))
958
+ }
959
+
960
+ return notEmulated('this JSON-RPC method is not emulated on the legacy profile')
961
+ }
962
+ }
963
+
964
+ /** `GET /legacy/mcp`: the client's standing event stream, answered the recorded 405. */
965
+ const legacyGetRoute: McpRoute = {
966
+ ...routeEvidenceOf('GET', mcpEmulatorLegacyPath, [getRow]),
967
+ body: 'none',
968
+ admit: (request, env) => {
969
+ const query = noQuery(request)
970
+
971
+ if (query !== undefined) return query
972
+
973
+ if (isReserved(request, env)) return notEmulated(reservedOnlyOnProbe)
974
+
975
+ const problem =
976
+ unrecordedMcpHeader(request) ??
977
+ headerProblem(request, recorded.legacyGet.request, comparedHeaders)
978
+
979
+ if (problem !== undefined) return problem
980
+
981
+ return admission(
982
+ getRow.path,
983
+ {
984
+ key: 'legacyGet',
985
+ id: undefined,
986
+ cursor: undefined,
987
+ session: request.header(sessionHeader)
988
+ },
989
+ planOnSession('legacyGet')
990
+ )
991
+ }
992
+ }
993
+
994
+ /** The route table: one wire route per recorded HTTP shape, each with its manifest variants. */
995
+ export const mcpApiRoutes: ReadonlyArray<McpRoute> = [modernRoute, legacyRoute, legacyGetRoute]