@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.
- package/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- 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]
|