@copilotkit/aimock 1.44.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +6 -0
- package/dist/agui-types.d.ts.map +1 -1
- package/dist/aimock-cli.cjs +31 -5
- package/dist/aimock-cli.cjs.map +1 -1
- package/dist/aimock-cli.d.cts.map +1 -1
- package/dist/aimock-cli.d.ts.map +1 -1
- package/dist/aimock-cli.js +31 -5
- package/dist/aimock-cli.js.map +1 -1
- package/dist/bedrock-converse.cjs +312 -39
- package/dist/bedrock-converse.cjs.map +1 -1
- package/dist/bedrock-converse.d.cts.map +1 -1
- package/dist/bedrock-converse.d.ts.map +1 -1
- package/dist/bedrock-converse.js +313 -40
- package/dist/bedrock-converse.js.map +1 -1
- package/dist/bedrock.cjs +195 -42
- package/dist/bedrock.cjs.map +1 -1
- package/dist/bedrock.d.cts.map +1 -1
- package/dist/bedrock.d.ts.map +1 -1
- package/dist/bedrock.js +196 -43
- package/dist/bedrock.js.map +1 -1
- package/dist/chaos.cjs +1 -0
- package/dist/chaos.js +1 -1
- package/dist/cli.cjs +152 -5
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +155 -8
- package/dist/cli.js.map +1 -1
- package/dist/cohere.cjs +132 -71
- package/dist/cohere.cjs.map +1 -1
- package/dist/cohere.d.cts.map +1 -1
- package/dist/cohere.d.ts.map +1 -1
- package/dist/cohere.js +133 -72
- package/dist/cohere.js.map +1 -1
- package/dist/config-loader.cjs +62 -4
- package/dist/config-loader.cjs.map +1 -1
- package/dist/config-loader.d.cts +37 -4
- package/dist/config-loader.d.cts.map +1 -1
- package/dist/config-loader.d.ts +37 -4
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/config-loader.js +62 -4
- package/dist/config-loader.js.map +1 -1
- package/dist/fakes-plugin.cjs +131 -0
- package/dist/fakes-plugin.cjs.map +1 -0
- package/dist/fakes-plugin.d.cts +23 -0
- package/dist/fakes-plugin.d.cts.map +1 -0
- package/dist/fakes-plugin.d.ts +23 -0
- package/dist/fakes-plugin.d.ts.map +1 -0
- package/dist/fakes-plugin.js +127 -0
- package/dist/fakes-plugin.js.map +1 -0
- package/dist/fixture-loader-services.cjs +1 -1
- package/dist/fixture-loader-services.cjs.map +1 -1
- package/dist/fixture-loader-services.js +1 -1
- package/dist/fixture-loader-services.js.map +1 -1
- package/dist/fixture-loader.cjs +389 -27
- package/dist/fixture-loader.cjs.map +1 -1
- package/dist/fixture-loader.d.cts +47 -3
- package/dist/fixture-loader.d.cts.map +1 -1
- package/dist/fixture-loader.d.ts +47 -3
- package/dist/fixture-loader.d.ts.map +1 -1
- package/dist/fixture-loader.js +384 -29
- package/dist/fixture-loader.js.map +1 -1
- package/dist/gemini-interactions.cjs +250 -52
- package/dist/gemini-interactions.cjs.map +1 -1
- package/dist/gemini-interactions.d.cts.map +1 -1
- package/dist/gemini-interactions.d.ts.map +1 -1
- package/dist/gemini-interactions.js +251 -53
- package/dist/gemini-interactions.js.map +1 -1
- package/dist/gemini.cjs +226 -37
- package/dist/gemini.cjs.map +1 -1
- package/dist/gemini.d.cts.map +1 -1
- package/dist/gemini.d.ts.map +1 -1
- package/dist/gemini.js +227 -38
- package/dist/gemini.js.map +1 -1
- package/dist/helpers.cjs +881 -7
- package/dist/helpers.cjs.map +1 -1
- package/dist/helpers.d.cts +85 -4
- package/dist/helpers.d.cts.map +1 -1
- package/dist/helpers.d.ts +85 -4
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +839 -8
- package/dist/helpers.js.map +1 -1
- package/dist/index.cjs +9 -0
- package/dist/index.d.cts +7 -5
- package/dist/index.d.ts +7 -5
- package/dist/index.js +4 -3
- package/dist/jest.cjs +31 -7
- package/dist/jest.cjs.map +1 -1
- package/dist/jest.d.cts +43 -3
- package/dist/jest.d.cts.map +1 -1
- package/dist/jest.d.ts +43 -3
- package/dist/jest.d.ts.map +1 -1
- package/dist/jest.js +33 -9
- package/dist/jest.js.map +1 -1
- package/dist/journal.cjs +135 -13
- package/dist/journal.cjs.map +1 -1
- package/dist/journal.d.cts +38 -4
- package/dist/journal.d.cts.map +1 -1
- package/dist/journal.d.ts +38 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +135 -13
- package/dist/journal.js.map +1 -1
- package/dist/live-sanitize.cjs +4 -32
- package/dist/live-sanitize.cjs.map +1 -1
- package/dist/live-sanitize.js +4 -32
- package/dist/live-sanitize.js.map +1 -1
- package/dist/llmock.cjs +123 -30
- package/dist/llmock.cjs.map +1 -1
- package/dist/llmock.d.cts +16 -1
- package/dist/llmock.d.cts.map +1 -1
- package/dist/llmock.d.ts +16 -1
- package/dist/llmock.d.ts.map +1 -1
- package/dist/llmock.js +125 -32
- package/dist/llmock.js.map +1 -1
- package/dist/mcp-fakes-mount.cjs +1 -0
- package/dist/mcp-fakes-mount.cjs.map +1 -1
- package/dist/mcp-fakes-mount.js +1 -0
- package/dist/mcp-fakes-mount.js.map +1 -1
- package/dist/mcp-fakes-report.cjs +111 -0
- package/dist/mcp-fakes-report.cjs.map +1 -0
- package/dist/mcp-fakes-report.d.cts +40 -0
- package/dist/mcp-fakes-report.d.cts.map +1 -0
- package/dist/mcp-fakes-report.d.ts +40 -0
- package/dist/mcp-fakes-report.d.ts.map +1 -0
- package/dist/mcp-fakes-report.js +107 -0
- package/dist/mcp-fakes-report.js.map +1 -0
- package/dist/mcp-fakes.cjs +331 -8
- package/dist/mcp-fakes.cjs.map +1 -1
- package/dist/mcp-fakes.d.cts +406 -3
- package/dist/mcp-fakes.d.cts.map +1 -1
- package/dist/mcp-fakes.d.ts +406 -3
- package/dist/mcp-fakes.d.ts.map +1 -1
- package/dist/mcp-fakes.js +331 -8
- package/dist/mcp-fakes.js.map +1 -1
- package/dist/mcp-handler.cjs +209 -16
- package/dist/mcp-handler.cjs.map +1 -1
- package/dist/mcp-handler.d.cts +49 -0
- package/dist/mcp-handler.d.cts.map +1 -0
- package/dist/mcp-handler.d.ts +49 -0
- package/dist/mcp-handler.d.ts.map +1 -0
- package/dist/mcp-handler.js +207 -17
- package/dist/mcp-handler.js.map +1 -1
- package/dist/mcp-mock.cjs +112 -1
- package/dist/mcp-mock.cjs.map +1 -1
- package/dist/mcp-mock.d.cts +33 -2
- package/dist/mcp-mock.d.cts.map +1 -1
- package/dist/mcp-mock.d.ts +33 -2
- package/dist/mcp-mock.d.ts.map +1 -1
- package/dist/mcp-mock.js +112 -1
- package/dist/mcp-mock.js.map +1 -1
- package/dist/mcp-recorder.cjs +1224 -0
- package/dist/mcp-recorder.cjs.map +1 -0
- package/dist/mcp-recorder.d.cts +108 -0
- package/dist/mcp-recorder.d.cts.map +1 -0
- package/dist/mcp-recorder.d.ts +108 -0
- package/dist/mcp-recorder.d.ts.map +1 -0
- package/dist/mcp-recorder.js +1215 -0
- package/dist/mcp-recorder.js.map +1 -0
- package/dist/messages.cjs +362 -56
- package/dist/messages.cjs.map +1 -1
- package/dist/messages.d.cts.map +1 -1
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +363 -58
- package/dist/messages.js.map +1 -1
- package/dist/misbehavior.cjs +1523 -0
- package/dist/misbehavior.cjs.map +1 -0
- package/dist/misbehavior.d.cts +43 -0
- package/dist/misbehavior.d.cts.map +1 -0
- package/dist/misbehavior.d.ts +43 -0
- package/dist/misbehavior.d.ts.map +1 -0
- package/dist/misbehavior.js +1511 -0
- package/dist/misbehavior.js.map +1 -0
- package/dist/ollama.cjs +310 -112
- package/dist/ollama.cjs.map +1 -1
- package/dist/ollama.d.cts.map +1 -1
- package/dist/ollama.d.ts.map +1 -1
- package/dist/ollama.js +311 -113
- package/dist/ollama.js.map +1 -1
- package/dist/record-sanitize.cjs +231 -0
- package/dist/record-sanitize.cjs.map +1 -0
- package/dist/record-sanitize.js +224 -0
- package/dist/record-sanitize.js.map +1 -0
- package/dist/recorder.cjs +106 -5
- package/dist/recorder.cjs.map +1 -1
- package/dist/recorder.d.cts +2 -1
- package/dist/recorder.d.cts.map +1 -1
- package/dist/recorder.d.ts +2 -1
- package/dist/recorder.d.ts.map +1 -1
- package/dist/recorder.js +107 -7
- package/dist/recorder.js.map +1 -1
- package/dist/responses.cjs +667 -171
- package/dist/responses.cjs.map +1 -1
- package/dist/responses.d.cts +5 -13
- package/dist/responses.d.cts.map +1 -1
- package/dist/responses.d.ts +5 -13
- package/dist/responses.d.ts.map +1 -1
- package/dist/responses.js +665 -174
- package/dist/responses.js.map +1 -1
- package/dist/router.cjs +24 -2
- package/dist/router.cjs.map +1 -1
- package/dist/router.d.cts.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +25 -4
- package/dist/router.js.map +1 -1
- package/dist/server.cjs +572 -76
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +574 -78
- package/dist/server.js.map +1 -1
- package/dist/stream-collapse.cjs +236 -34
- package/dist/stream-collapse.cjs.map +1 -1
- package/dist/stream-collapse.d.cts +39 -4
- package/dist/stream-collapse.d.cts.map +1 -1
- package/dist/stream-collapse.d.ts +39 -4
- package/dist/stream-collapse.d.ts.map +1 -1
- package/dist/stream-collapse.js +235 -35
- package/dist/stream-collapse.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +315 -4
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +315 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate-cli.cjs +36 -4
- package/dist/validate-cli.cjs.map +1 -1
- package/dist/validate-cli.js +37 -5
- package/dist/validate-cli.js.map +1 -1
- package/dist/vector-types.d.ts.map +1 -1
- package/dist/version.cjs +45 -0
- package/dist/version.cjs.map +1 -0
- package/dist/version.js +44 -0
- package/dist/version.js.map +1 -0
- package/dist/vitest.cjs +66 -10
- package/dist/vitest.cjs.map +1 -1
- package/dist/vitest.d.cts +35 -4
- package/dist/vitest.d.cts.map +1 -1
- package/dist/vitest.d.ts +35 -4
- package/dist/vitest.d.ts.map +1 -1
- package/dist/vitest.js +67 -11
- package/dist/vitest.js.map +1 -1
- package/dist/watcher.cjs +6 -2
- package/dist/watcher.cjs.map +1 -1
- package/dist/watcher.js +6 -2
- package/dist/watcher.js.map +1 -1
- package/dist/ws-gemini-live.cjs +210 -101
- package/dist/ws-gemini-live.cjs.map +1 -1
- package/dist/ws-gemini-live.d.cts +9 -3
- package/dist/ws-gemini-live.d.cts.map +1 -1
- package/dist/ws-gemini-live.d.ts +9 -3
- package/dist/ws-gemini-live.d.ts.map +1 -1
- package/dist/ws-gemini-live.js +211 -102
- package/dist/ws-gemini-live.js.map +1 -1
- package/dist/ws-realtime.cjs +175 -62
- package/dist/ws-realtime.cjs.map +1 -1
- package/dist/ws-realtime.d.cts +8 -3
- package/dist/ws-realtime.d.cts.map +1 -1
- package/dist/ws-realtime.d.ts +8 -3
- package/dist/ws-realtime.d.ts.map +1 -1
- package/dist/ws-realtime.js +176 -63
- package/dist/ws-realtime.js.map +1 -1
- package/dist/ws-responses.cjs +109 -16
- package/dist/ws-responses.cjs.map +1 -1
- package/dist/ws-responses.d.cts +8 -12
- package/dist/ws-responses.d.cts.map +1 -1
- package/dist/ws-responses.d.ts +8 -12
- package/dist/ws-responses.d.ts.map +1 -1
- package/dist/ws-responses.js +111 -18
- package/dist/ws-responses.js.map +1 -1
- package/package.json +10 -2
- package/skills/write-fixtures/SKILL.md +179 -21
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
import { build, fixed, msg, plainText } from "./message-text.js";
|
|
2
|
+
import { isAuthenticatedRequest } from "./api-key-auth.js";
|
|
3
|
+
import { flattenHeaders, readBody, resolveMcpIdentity, resolveStrictMode, slugifyContext, slugifyTestId } from "./helpers.js";
|
|
4
|
+
import { buildForwardHeaders, persistServiceFakes, removeForwardHeader, sanitizeHeaderValue } from "./recorder.js";
|
|
5
|
+
import { Logger } from "./logger.js";
|
|
6
|
+
import { REDACTED, RecordUnsafeError, knownSecrets, sanitizeRecording, scrubUrl, validateSecretValues } from "./record-sanitize.js";
|
|
7
|
+
import { MCP_FAKE_ERROR_CODES } from "./types.js";
|
|
8
|
+
import { fakeFailure, writeFakeToolAnswer } from "./mcp-handler.js";
|
|
9
|
+
import { MCP_FAKES_DEFAULT_TEST_ID, validateMcpFakes } from "./mcp-fakes.js";
|
|
10
|
+
import { aimockVersion } from "./version.js";
|
|
11
|
+
import * as http$1 from "node:http";
|
|
12
|
+
import * as crypto$1 from "node:crypto";
|
|
13
|
+
import * as https from "node:https";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { StringDecoder } from "node:string_decoder";
|
|
17
|
+
|
|
18
|
+
//#region src/mcp-recorder.ts
|
|
19
|
+
/**
|
|
20
|
+
* MCP record mode (rr: MR1-MR17, par: AM1-AM9): a pass-through proxy inside
|
|
21
|
+
* `MCPMock` that forwards to a real upstream MCP server, relays its answers,
|
|
22
|
+
* and writes the `tools/list` and `tools/call` exchanges it sees as an
|
|
23
|
+
* `mcpFakes` file (FA1-FA5), sanitized (S1-S7).
|
|
24
|
+
*
|
|
25
|
+
* Every file this module writes goes through `sanitizeRecording` and then
|
|
26
|
+
* `persistServiceFakes`; there is no other filesystem write here.
|
|
27
|
+
*/
|
|
28
|
+
/** AM4: the protocol versions this recorder writes (legacy era only). */
|
|
29
|
+
const RECORDABLE_VERSIONS = new Set([
|
|
30
|
+
"2025-03-26",
|
|
31
|
+
"2025-06-18",
|
|
32
|
+
"2025-11-25"
|
|
33
|
+
]);
|
|
34
|
+
/** MR2 (d): default bytes of one recordable response that are buffered. */
|
|
35
|
+
const DEFAULT_RECORD_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
36
|
+
/** MR2 (d): the ceiling `maxRecordBufferBytes` is clamped to. */
|
|
37
|
+
const MAX_RECORD_BUFFER_BYTES = 256 * 1024 * 1024;
|
|
38
|
+
/** The MR14 body of a 502 answered for an upstream auth failure. */
|
|
39
|
+
const AUTH_FAILURE_ERROR = "aimock MCP recording got an upstream auth failure; OAuth discovery is not supported through the recorder; record with a pre-acquired token via upstreamAuth or the client's Authorization header";
|
|
40
|
+
/** Hop-by-hop response headers that are never relayed. */
|
|
41
|
+
const HOP_BY_HOP = new Set([
|
|
42
|
+
"connection",
|
|
43
|
+
"keep-alive",
|
|
44
|
+
"transfer-encoding",
|
|
45
|
+
"te",
|
|
46
|
+
"trailer",
|
|
47
|
+
"upgrade",
|
|
48
|
+
"proxy-authenticate",
|
|
49
|
+
"proxy-authorization"
|
|
50
|
+
]);
|
|
51
|
+
/** The identity query parameters aimock reads and never forwards (MR2 c). */
|
|
52
|
+
const IDENTITY_QUERY = new Set([
|
|
53
|
+
"testId",
|
|
54
|
+
"context",
|
|
55
|
+
"undeclared"
|
|
56
|
+
]);
|
|
57
|
+
/** MR7: `_meta` keys dropped from a recorded result. */
|
|
58
|
+
const DROPPED_META = [
|
|
59
|
+
"traceparent",
|
|
60
|
+
"tracestate",
|
|
61
|
+
"baggage",
|
|
62
|
+
"io.modelcontextprotocol/serverInfo"
|
|
63
|
+
];
|
|
64
|
+
const SERVER_INFO_META = "io.modelcontextprotocol/serverInfo";
|
|
65
|
+
const RELATED_TASK_META = "io.modelcontextprotocol/related-task";
|
|
66
|
+
const PROTOCOL_VERSION_META = "io.modelcontextprotocol/protocolVersion";
|
|
67
|
+
/** The version assumed when nothing names one (MR4 source 4). */
|
|
68
|
+
const FALLBACK_VERSION = "2025-03-26";
|
|
69
|
+
/** A header token (RFC 9110 `token`). */
|
|
70
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
71
|
+
/**
|
|
72
|
+
* MR14 / AM6: parse a `Name: value` header, from `upstreamAuth` or
|
|
73
|
+
* `AIMOCK_MCP_UPSTREAM_AUTH`. `undefined` or an empty string is no header. A
|
|
74
|
+
* malformed value is a start error that names `source`, never the value.
|
|
75
|
+
*/
|
|
76
|
+
function parseUpstreamAuth(raw, source = "AIMOCK_MCP_UPSTREAM_AUTH") {
|
|
77
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
78
|
+
const colon = raw.indexOf(":");
|
|
79
|
+
const name = colon === -1 ? "" : raw.slice(0, colon).trim();
|
|
80
|
+
const value = colon === -1 ? "" : raw.slice(colon + 1).trim();
|
|
81
|
+
if (!HEADER_NAME.test(name) || value === "") throw new Error(`${source} must be "Name: value" (a header name, a colon, then a value)`);
|
|
82
|
+
return {
|
|
83
|
+
name,
|
|
84
|
+
value
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The MCP recorder of one `MCPMock`. `handle` always answers the request:
|
|
89
|
+
* by a fake (MR4), a fake error (MR5), or by forwarding it (MR2).
|
|
90
|
+
*/
|
|
91
|
+
var McpRecorder = class {
|
|
92
|
+
target;
|
|
93
|
+
auth;
|
|
94
|
+
maxBuffer;
|
|
95
|
+
fixturePath;
|
|
96
|
+
secretValues;
|
|
97
|
+
fallbackLogger = new Logger("warn");
|
|
98
|
+
/** MR3: upstream `Mcp-Session-Id` → what its `initialize` bound. */
|
|
99
|
+
sessions = /* @__PURE__ */ new Map();
|
|
100
|
+
/** MR16: pending lists per `${mount}\0${scope}`. */
|
|
101
|
+
pendingLists = /* @__PURE__ */ new Map();
|
|
102
|
+
/** MR16: completed lists waiting for their block's first call entry. */
|
|
103
|
+
completedLists = /* @__PURE__ */ new Map();
|
|
104
|
+
/** MR13: resolved path → sha256 of the last write. */
|
|
105
|
+
hashes = /* @__PURE__ */ new Map();
|
|
106
|
+
listeners = /* @__PURE__ */ new Set();
|
|
107
|
+
/** RL3: mounts already warned about a request with no scope. */
|
|
108
|
+
warnedNoScope = /* @__PURE__ */ new Set();
|
|
109
|
+
constructor(host, config) {
|
|
110
|
+
this.host = host;
|
|
111
|
+
this.config = config;
|
|
112
|
+
let target;
|
|
113
|
+
try {
|
|
114
|
+
target = new URL(config.upstream);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error("MCP record upstream is not a valid URL");
|
|
117
|
+
}
|
|
118
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") throw new Error("MCP record upstream must be an http: or https: URL");
|
|
119
|
+
this.target = target;
|
|
120
|
+
this.secretValues = config.secretValues ?? [];
|
|
121
|
+
validateSecretValues(this.secretValues);
|
|
122
|
+
this.auth = parseUpstreamAuth(config.upstreamAuth, "upstreamAuth");
|
|
123
|
+
const max = config.maxRecordBufferBytes;
|
|
124
|
+
this.maxBuffer = typeof max === "number" && Number.isFinite(max) && max > 0 ? Math.min(Math.floor(max), MAX_RECORD_BUFFER_BYTES) : DEFAULT_RECORD_BUFFER_BYTES;
|
|
125
|
+
this.fixturePath = config.fixturePath ?? "./fixtures/recorded";
|
|
126
|
+
if (!config.proxyOnly) checkMcpRecordDestination(this.fixturePath);
|
|
127
|
+
}
|
|
128
|
+
/** AM1 entry point. Reads the body itself. Always handles the request. */
|
|
129
|
+
async handle(req, res, mount, subPath) {
|
|
130
|
+
const x = {
|
|
131
|
+
req,
|
|
132
|
+
res,
|
|
133
|
+
mount,
|
|
134
|
+
subPath,
|
|
135
|
+
arrivedAt: performance.now(),
|
|
136
|
+
identity: null,
|
|
137
|
+
supplied: {
|
|
138
|
+
testId: false,
|
|
139
|
+
context: false,
|
|
140
|
+
undeclared: false
|
|
141
|
+
},
|
|
142
|
+
message: null,
|
|
143
|
+
rpcMethod: null,
|
|
144
|
+
rpcId: void 0,
|
|
145
|
+
params: void 0,
|
|
146
|
+
proxied: false
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
const body = await readBody(req);
|
|
150
|
+
const sessionId = headerOf(req, "mcp-session-id");
|
|
151
|
+
const resolution = resolveMcpIdentity(req, sessionId ? this.sessions.get(sessionId) : void 0);
|
|
152
|
+
if (!resolution.ok) {
|
|
153
|
+
res.writeHead(resolution.error.status, { "Content-Type": "application/json" });
|
|
154
|
+
res.end(JSON.stringify({
|
|
155
|
+
error: resolution.error.message,
|
|
156
|
+
code: resolution.error.code
|
|
157
|
+
}));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
x.identity = resolution.identity;
|
|
161
|
+
x.supplied = resolution.supplied;
|
|
162
|
+
let parsed;
|
|
163
|
+
let parsedOk = false;
|
|
164
|
+
if (body !== "") try {
|
|
165
|
+
parsed = JSON.parse(body);
|
|
166
|
+
parsedOk = true;
|
|
167
|
+
} catch {}
|
|
168
|
+
if (parsedOk && Array.isArray(parsed)) {
|
|
169
|
+
this.skip(x, "batch", "batch");
|
|
170
|
+
await this.forward(x, body, null);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (parsedOk && isObject(parsed) && typeof parsed.method === "string") {
|
|
174
|
+
x.rpcMethod = parsed.method;
|
|
175
|
+
const isRequest = "id" in parsed;
|
|
176
|
+
if (typeof parsed.id === "string" || typeof parsed.id === "number") x.rpcId = parsed.id;
|
|
177
|
+
if (isObject(parsed.params)) x.params = parsed.params;
|
|
178
|
+
if (parsed.method === "tools/call") x.message = parsed;
|
|
179
|
+
if (parsed.method === "initialize" && isRequest) {
|
|
180
|
+
await this.forward(x, body, "initialize");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (parsed.method === "tools/list" && isRequest) {
|
|
184
|
+
await this.forward(x, body, "tools/list");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (parsed.method === "tools/call" && isRequest) {
|
|
188
|
+
if (await this.answerByFake(x, parsed)) return;
|
|
189
|
+
this.logForwardedCall(x);
|
|
190
|
+
await this.forward(x, body, "tools/call");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (isRequest) this.skip(x, "not-tools", parsed.method);
|
|
194
|
+
await this.forward(x, body, null);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (body !== "" && req.method === "POST") this.skip(x, "not-tools", "(no method)");
|
|
198
|
+
await this.forward(x, body, null);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const thrown = err instanceof Error ? err.message : String(err);
|
|
201
|
+
this.log().error(build(msg`MCP-RECORD: request error on mount ${plainText(mount)}: ${plainText(thrown)}`));
|
|
202
|
+
if (!res.headersSent) {
|
|
203
|
+
res.writeHead(500);
|
|
204
|
+
res.end("Internal server error");
|
|
205
|
+
} else if (!res.writableEnded) res.end();
|
|
206
|
+
} finally {
|
|
207
|
+
this.journal(x);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** MR3 / MR16 reset doors (fakes:R1-R5, R8): one test id, or everything. */
|
|
211
|
+
reset(testId) {
|
|
212
|
+
for (const [id, session] of this.sessions) if (testId === void 0 || session.testId === testId) this.sessions.delete(id);
|
|
213
|
+
for (const lists of [this.pendingLists, this.completedLists]) for (const [key, list] of lists) {
|
|
214
|
+
if (testId !== void 0 && list.testId !== testId) continue;
|
|
215
|
+
lists.delete(key);
|
|
216
|
+
this.log().warn(build(msg`MCP-RECORD: forwarded, not recorded (list-incomplete): tools/list on mount ${plainText(mountOfKey(key))}`));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** MR13: the sha256 of the last write to `filepath`, if this recorder wrote it. */
|
|
220
|
+
lastWrittenHash(filepath) {
|
|
221
|
+
return this.hashes.get(path.resolve(filepath));
|
|
222
|
+
}
|
|
223
|
+
/** MR13: called after each write with the resolved path and the `mcpFakes` written. */
|
|
224
|
+
onWrite(listener) {
|
|
225
|
+
this.listeners.add(listener);
|
|
226
|
+
return () => void this.listeners.delete(listener);
|
|
227
|
+
}
|
|
228
|
+
/** @internal MR3: whether the record-session map holds `sessionId` (tests). */
|
|
229
|
+
hasRecordSession(sessionId) {
|
|
230
|
+
return this.sessions.has(sessionId);
|
|
231
|
+
}
|
|
232
|
+
/** MR16: drop pending lists (RL2 list-incomplete) and clear the session map. */
|
|
233
|
+
close() {
|
|
234
|
+
this.reset();
|
|
235
|
+
this.listeners.clear();
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* MR4 / MR5 for one `tools/call` request: answer it by a fake, or by a fake
|
|
239
|
+
* error (deny, strict, eviction), and return true; false to forward it.
|
|
240
|
+
*/
|
|
241
|
+
async answerByFake(x, message) {
|
|
242
|
+
const params = message.params;
|
|
243
|
+
const name = isObject(params) ? params.name : void 0;
|
|
244
|
+
const id = message.id;
|
|
245
|
+
if (typeof name !== "string" || name === "" || typeof id !== "string" && typeof id !== "number") return false;
|
|
246
|
+
const identity = x.identity;
|
|
247
|
+
const args = isObject(params) ? params.arguments : void 0;
|
|
248
|
+
const event = {
|
|
249
|
+
identity,
|
|
250
|
+
tool: name,
|
|
251
|
+
mount: x.mount,
|
|
252
|
+
args
|
|
253
|
+
};
|
|
254
|
+
let claim;
|
|
255
|
+
try {
|
|
256
|
+
claim = this.host.fakes.claim(name, args, identity);
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
if (claim.kind === "answer") {
|
|
261
|
+
x.mcpFake = {
|
|
262
|
+
id: claim.entry.id,
|
|
263
|
+
outcome: "answered"
|
|
264
|
+
};
|
|
265
|
+
this.host.onFakeEvent({
|
|
266
|
+
...event,
|
|
267
|
+
kind: "answered",
|
|
268
|
+
entryId: claim.entry.id
|
|
269
|
+
});
|
|
270
|
+
await writeFakeToolAnswer(x.req, x.res, id, claim.result, this.host.fakes.replayOf(claim.entry.id), params, this.host.replaySpeed(), null, x.arrivedAt);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
const strict = resolveStrictMode(this.config.strict, x.req.headers);
|
|
274
|
+
const ctx = {
|
|
275
|
+
identity,
|
|
276
|
+
mount: x.mount,
|
|
277
|
+
notifications: [],
|
|
278
|
+
calls: []
|
|
279
|
+
};
|
|
280
|
+
let failure = null;
|
|
281
|
+
if (claim.kind === "none") {
|
|
282
|
+
if (this.host.fakes.policy(identity) === "deny" || strict) failure = fakeFailure({ kind: "not_declared" }, name, ctx, () => this.host.fakes.declaredTools(identity));
|
|
283
|
+
} else if (claim.kind === "evicted" || strict) failure = fakeFailure(claim, name, ctx, () => []);
|
|
284
|
+
if (!failure) return false;
|
|
285
|
+
x.mcpFake = {
|
|
286
|
+
id: null,
|
|
287
|
+
outcome: failure.outcome
|
|
288
|
+
};
|
|
289
|
+
this.host.onFakeEvent(failure.outcome === "evicted" ? {
|
|
290
|
+
...event,
|
|
291
|
+
kind: "evicted",
|
|
292
|
+
code: failure.code,
|
|
293
|
+
message: failure.message,
|
|
294
|
+
...failure.evicted
|
|
295
|
+
} : {
|
|
296
|
+
...event,
|
|
297
|
+
kind: failure.outcome,
|
|
298
|
+
code: failure.code,
|
|
299
|
+
message: failure.message
|
|
300
|
+
});
|
|
301
|
+
x.res.writeHead(200, { "Content-Type": "application/json" });
|
|
302
|
+
x.res.end(JSON.stringify({
|
|
303
|
+
jsonrpc: "2.0",
|
|
304
|
+
id,
|
|
305
|
+
error: {
|
|
306
|
+
code: MCP_FAKE_ERROR_CODES[failure.code],
|
|
307
|
+
message: failure.message,
|
|
308
|
+
data: { aimock: failure.aimock }
|
|
309
|
+
}
|
|
310
|
+
}));
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
/** RP2: a forwarded `tools/call` is logged as unfaked, answered by the upstream. */
|
|
314
|
+
logForwardedCall(x) {
|
|
315
|
+
const name = x.params?.name;
|
|
316
|
+
if (typeof name !== "string" || x.identity === null) return;
|
|
317
|
+
this.host.fakes.logEvent(x.identity, {
|
|
318
|
+
outcome: "unfaked",
|
|
319
|
+
answeredBy: "upstream",
|
|
320
|
+
tool: name,
|
|
321
|
+
args: x.params?.arguments,
|
|
322
|
+
context: x.identity.context,
|
|
323
|
+
mount: x.mount
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/** Forward one request to the upstream and relay its answer; record it when `kind` says so. */
|
|
327
|
+
forward(x, body, kind) {
|
|
328
|
+
const { req, res } = x;
|
|
329
|
+
x.proxied = true;
|
|
330
|
+
if (isAuthenticatedRequest(req) && !this.auth) {
|
|
331
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
332
|
+
res.end(JSON.stringify({ error: "No configured upstream credential" }));
|
|
333
|
+
return Promise.resolve();
|
|
334
|
+
}
|
|
335
|
+
const headers = buildForwardHeaders(req);
|
|
336
|
+
removeForwardHeader(headers, "x-aimock-mcp-undeclared");
|
|
337
|
+
if (this.auth) {
|
|
338
|
+
removeForwardHeader(headers, this.auth.name);
|
|
339
|
+
headers[this.auth.name] = this.auth.value;
|
|
340
|
+
}
|
|
341
|
+
if (body !== "") headers["content-length"] = String(Buffer.byteLength(body));
|
|
342
|
+
const url = this.upstreamUrl(req, x.subPath);
|
|
343
|
+
const transport = url.protocol === "https:" ? https : http$1;
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
let done = false;
|
|
346
|
+
const finish = () => {
|
|
347
|
+
if (done) return;
|
|
348
|
+
done = true;
|
|
349
|
+
resolve();
|
|
350
|
+
};
|
|
351
|
+
const upstreamReq = transport.request(url, {
|
|
352
|
+
method: req.method ?? "POST",
|
|
353
|
+
headers
|
|
354
|
+
});
|
|
355
|
+
res.on("close", () => {
|
|
356
|
+
if (!upstreamReq.destroyed) upstreamReq.destroy();
|
|
357
|
+
finish();
|
|
358
|
+
});
|
|
359
|
+
upstreamReq.on("error", (err) => {
|
|
360
|
+
if (done) return;
|
|
361
|
+
this.log().error(build(msg`MCP-RECORD: upstream request failed for ${plainText(x.rpcMethod ?? req.method ?? "?")} on mount ${plainText(x.mount)}: ${plainText(err.message)}`));
|
|
362
|
+
if (!res.headersSent) {
|
|
363
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
364
|
+
res.end(JSON.stringify({ error: "aimock MCP recorder could not reach the upstream" }));
|
|
365
|
+
} else if (!res.writableEnded) res.end();
|
|
366
|
+
finish();
|
|
367
|
+
});
|
|
368
|
+
upstreamReq.on("response", (up) => {
|
|
369
|
+
this.relay(x, up, kind, finish);
|
|
370
|
+
});
|
|
371
|
+
upstreamReq.end(body === "" ? void 0 : body);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
/** The upstream URL of a request: the endpoint plus the sub-path, the query minus identity params. */
|
|
375
|
+
upstreamUrl(req, subPath) {
|
|
376
|
+
const url = new URL(this.target.href);
|
|
377
|
+
if (subPath !== "" && subPath !== "/") url.pathname = url.pathname.replace(/\/+$/, "") + subPath;
|
|
378
|
+
const raw = (req.url ?? "").split("#")[0];
|
|
379
|
+
const q = raw.indexOf("?");
|
|
380
|
+
const kept = q === -1 ? [] : raw.slice(q + 1).split("&").filter((pair) => pair !== "" && !IDENTITY_QUERY.has(queryName(pair)));
|
|
381
|
+
if (kept.length > 0) url.search = [url.search.replace(/^\?/, ""), ...kept].filter((part) => part !== "").join("&");
|
|
382
|
+
return url;
|
|
383
|
+
}
|
|
384
|
+
/** MR2 (d), MR14: relay one upstream response, buffering a recordable one. */
|
|
385
|
+
relay(x, up, kind, finish) {
|
|
386
|
+
const { req, res } = x;
|
|
387
|
+
const status = up.statusCode ?? 502;
|
|
388
|
+
const sessionId = headerOf(req, "mcp-session-id");
|
|
389
|
+
const rpcId = x.rpcId;
|
|
390
|
+
const UPSTREAM_ABORTED_ERROR = "aimock MCP recorder: the upstream response failed";
|
|
391
|
+
let processed = false;
|
|
392
|
+
let clientGone = false;
|
|
393
|
+
res.on("close", () => {
|
|
394
|
+
if (!res.writableFinished) clientGone = true;
|
|
395
|
+
});
|
|
396
|
+
let sseError = null;
|
|
397
|
+
up.on("error", (err) => {
|
|
398
|
+
if (clientGone || res.writableEnded) {
|
|
399
|
+
finish();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const answered = processed;
|
|
403
|
+
const recorded = answered && x.recordSkipped === void 0 && x.recordError === void 0;
|
|
404
|
+
processed = true;
|
|
405
|
+
if (!recorded) x.recordSkipped = "upstream-aborted";
|
|
406
|
+
this.log().error(build(msg`MCP-RECORD: the upstream response failed mid-body (${fixed("upstream-aborted")}) for ${plainText(x.rpcMethod ?? req.method ?? "?")} on mount ${plainText(x.mount)}: ${plainText(err.message)}`));
|
|
407
|
+
if (!res.headersSent) {
|
|
408
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
409
|
+
res.end(JSON.stringify({ error: UPSTREAM_ABORTED_ERROR }));
|
|
410
|
+
finish();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (!answered && sseError !== null && !res.writableEnded) res.write(sseError(), () => res.destroy());
|
|
414
|
+
else res.destroy();
|
|
415
|
+
finish();
|
|
416
|
+
});
|
|
417
|
+
const challenge = String(up.headers["www-authenticate"] ?? "");
|
|
418
|
+
if (status === 401 || status === 403 && /insufficient_scope/i.test(challenge)) {
|
|
419
|
+
up.resume();
|
|
420
|
+
x.recordSkipped = "upstream-auth";
|
|
421
|
+
this.log().error(build(msg`MCP-RECORD: recording refused (upstream-auth): the upstream answered ${status} for ${plainText(x.rpcMethod ?? req.method ?? "?")} on mount ${plainText(x.mount)}`));
|
|
422
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
423
|
+
res.end(JSON.stringify({
|
|
424
|
+
error: AUTH_FAILURE_ERROR,
|
|
425
|
+
upstreamStatus: status
|
|
426
|
+
}));
|
|
427
|
+
finish();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (sessionId && (status === 404 || req.method === "DELETE" && status >= 200 && status < 300)) this.sessions.delete(sessionId);
|
|
431
|
+
const headers = {};
|
|
432
|
+
for (const [name, value] of Object.entries(up.headers)) {
|
|
433
|
+
if (value === void 0 || HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
434
|
+
headers[name] = value;
|
|
435
|
+
}
|
|
436
|
+
const ok = status >= 200 && status < 300;
|
|
437
|
+
if (kind === "tools/call" && !ok) this.skip(x, "upstream-status", "tools/call");
|
|
438
|
+
if (kind === "tools/list" && !ok) this.skip(x, "upstream-status", "tools/list");
|
|
439
|
+
if (kind === null || !ok) {
|
|
440
|
+
res.writeHead(status, headers);
|
|
441
|
+
up.pipe(res);
|
|
442
|
+
up.on("end", finish);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const isSse = String(up.headers["content-type"] ?? "").includes("text/event-stream");
|
|
446
|
+
const messages = [];
|
|
447
|
+
let size = 0;
|
|
448
|
+
let overflow = false;
|
|
449
|
+
const process = () => {
|
|
450
|
+
if (processed) return;
|
|
451
|
+
processed = true;
|
|
452
|
+
if (overflow) {
|
|
453
|
+
this.skip(x, "buffer-cap", kind);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
this.onRecordable(x, kind, messages, up);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
this.fail(x, "record-error", err instanceof Error ? err.name : typeof err);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
if (isSse) {
|
|
463
|
+
res.writeHead(status, headers);
|
|
464
|
+
res.flushHeaders();
|
|
465
|
+
const decoder = new StringDecoder("utf8");
|
|
466
|
+
let pending = "";
|
|
467
|
+
if (rpcId !== void 0) {
|
|
468
|
+
const error = {
|
|
469
|
+
code: -32603,
|
|
470
|
+
message: UPSTREAM_ABORTED_ERROR
|
|
471
|
+
};
|
|
472
|
+
sseError = () => {
|
|
473
|
+
return `${pending === "" ? "" : `${/[\r\n]$/.test(pending) ? "" : "\n"}event: aimock-discarded\n\n`}event: message\ndata: ${JSON.stringify({
|
|
474
|
+
jsonrpc: "2.0",
|
|
475
|
+
id: rpcId,
|
|
476
|
+
error
|
|
477
|
+
})}\n\n`;
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
up.on("data", (chunk) => {
|
|
481
|
+
if (!processed && !overflow) {
|
|
482
|
+
size += chunk.length;
|
|
483
|
+
if (size > this.maxBuffer) {
|
|
484
|
+
overflow = true;
|
|
485
|
+
process();
|
|
486
|
+
} else {
|
|
487
|
+
pending += decoder.write(chunk);
|
|
488
|
+
const events = pending.split(/\r?\n\r?\n/);
|
|
489
|
+
pending = events.pop() ?? "";
|
|
490
|
+
for (const event of events) for (const message of sseMessages(event)) messages.push({
|
|
491
|
+
at: performance.now() - x.arrivedAt,
|
|
492
|
+
message
|
|
493
|
+
});
|
|
494
|
+
if (messages.some((m) => isResponseTo(m.message, rpcId))) process();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
res.write(chunk);
|
|
498
|
+
});
|
|
499
|
+
up.on("end", () => {
|
|
500
|
+
if (!processed && !overflow) for (const message of sseMessages(pending + decoder.end())) messages.push({
|
|
501
|
+
at: performance.now() - x.arrivedAt,
|
|
502
|
+
message
|
|
503
|
+
});
|
|
504
|
+
process();
|
|
505
|
+
res.end();
|
|
506
|
+
finish();
|
|
507
|
+
});
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const chunks = [];
|
|
511
|
+
up.on("data", (chunk) => {
|
|
512
|
+
if (overflow) {
|
|
513
|
+
res.write(chunk);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
size += chunk.length;
|
|
517
|
+
if (size > this.maxBuffer) {
|
|
518
|
+
overflow = true;
|
|
519
|
+
process();
|
|
520
|
+
res.writeHead(status, headers);
|
|
521
|
+
for (const c of chunks) res.write(c);
|
|
522
|
+
res.write(chunk);
|
|
523
|
+
chunks.length = 0;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
chunks.push(chunk);
|
|
527
|
+
});
|
|
528
|
+
up.on("end", () => {
|
|
529
|
+
if (!overflow) {
|
|
530
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
531
|
+
try {
|
|
532
|
+
const parsed = JSON.parse(text);
|
|
533
|
+
for (const message of Array.isArray(parsed) ? parsed : [parsed]) if (isObject(message)) messages.push({
|
|
534
|
+
at: performance.now() - x.arrivedAt,
|
|
535
|
+
message
|
|
536
|
+
});
|
|
537
|
+
} catch {}
|
|
538
|
+
process();
|
|
539
|
+
if (x.recordError !== void 0) headers["X-AIMock-Record-Error"] = sanitizeHeaderValue(x.recordError);
|
|
540
|
+
res.writeHead(status, headers);
|
|
541
|
+
res.end(text === "" ? void 0 : Buffer.concat(chunks));
|
|
542
|
+
} else res.end();
|
|
543
|
+
finish();
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/** A recordable response, once its JSON-RPC response arrived (or its stream ended). */
|
|
547
|
+
onRecordable(x, kind, messages, up) {
|
|
548
|
+
const response = messages.find((m) => isResponseTo(m.message, x.rpcId));
|
|
549
|
+
if (kind === "initialize") {
|
|
550
|
+
this.onInitialize(x, response?.message, up);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (!response) {
|
|
554
|
+
this.skip(x, "upstream-error", kind);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const reason = kind === "tools/call" ? callSkipReason(response.message, messages) : isObject(response.message.result) ? null : "upstream-error";
|
|
558
|
+
if (reason !== null) {
|
|
559
|
+
this.skip(x, reason, kind);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (!this.versionRecordable(x)) {
|
|
563
|
+
this.skip(x, "unsupported-version", kind);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const identity = x.identity;
|
|
567
|
+
if (identity.testId === null && identity.context === null) {
|
|
568
|
+
x.recordSkipped = "no-scope";
|
|
569
|
+
if (!this.warnedNoScope.has(x.mount)) {
|
|
570
|
+
this.warnedNoScope.add(x.mount);
|
|
571
|
+
this.log().warn(build(msg`MCP-RECORD: forwarded, not recorded: a request on mount ${plainText(x.mount)} has no testId or context, so it has no recording scope (logged once per mount)`));
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (this.config.proxyOnly) return;
|
|
576
|
+
if (kind === "tools/list") this.onToolsList(x, response.message);
|
|
577
|
+
else this.recordCall(x, response, messages);
|
|
578
|
+
}
|
|
579
|
+
/** MR3 / AM4: bind what an upstream `initialize` negotiated to its session. */
|
|
580
|
+
onInitialize(x, response, up) {
|
|
581
|
+
const result = response?.result;
|
|
582
|
+
if (!isObject(result)) return;
|
|
583
|
+
const version = typeof result.protocolVersion === "string" ? result.protocolVersion : null;
|
|
584
|
+
const recordable = version !== null && RECORDABLE_VERSIONS.has(version);
|
|
585
|
+
if (!recordable) this.skip(x, "unsupported-version", "initialize");
|
|
586
|
+
const sessionId = headerOf(up, "mcp-session-id");
|
|
587
|
+
if (!sessionId) return;
|
|
588
|
+
const identity = x.identity;
|
|
589
|
+
this.sessions.set(sessionId, {
|
|
590
|
+
protocolVersion: version,
|
|
591
|
+
recordable,
|
|
592
|
+
...isObject(result.serverInfo) ? { serverInfo: result.serverInfo } : {},
|
|
593
|
+
clientCapabilities: x.params?.capabilities,
|
|
594
|
+
clientInfo: x.params?.clientInfo,
|
|
595
|
+
...x.supplied.testId && identity.testId !== null ? { testId: identity.testId } : {},
|
|
596
|
+
...x.supplied.context && identity.context !== null ? { context: identity.context } : {},
|
|
597
|
+
...x.supplied.undeclared && identity.undeclared !== null ? { undeclared: identity.undeclared } : {}
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/** AM4: false when the session negotiated, or the request names, a version not recorded. */
|
|
601
|
+
versionRecordable(x) {
|
|
602
|
+
const sessionId = headerOf(x.req, "mcp-session-id");
|
|
603
|
+
const session = sessionId ? this.sessions.get(sessionId) : void 0;
|
|
604
|
+
if (session && !session.recordable) return false;
|
|
605
|
+
const header = headerOf(x.req, "mcp-protocol-version");
|
|
606
|
+
return header === void 0 || RECORDABLE_VERSIONS.has(header);
|
|
607
|
+
}
|
|
608
|
+
/** MR16: collect a `tools/list` page; write the list when it completes. */
|
|
609
|
+
onToolsList(x, response) {
|
|
610
|
+
const result = response.result;
|
|
611
|
+
if (!Array.isArray(result.tools)) {
|
|
612
|
+
this.skip(x, "upstream-error", "tools/list");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const identity = x.identity;
|
|
616
|
+
const key = listKey(x.mount, identity);
|
|
617
|
+
const cursor = x.params?.cursor;
|
|
618
|
+
const next = typeof result.nextCursor === "string" ? result.nextCursor : void 0;
|
|
619
|
+
let tools;
|
|
620
|
+
if (cursor === void 0) {
|
|
621
|
+
if (this.pendingLists.has(key)) this.log().warn(build(msg`MCP-RECORD: forwarded, not recorded (list-incomplete): tools/list on mount ${plainText(x.mount)}`));
|
|
622
|
+
tools = [...result.tools];
|
|
623
|
+
} else {
|
|
624
|
+
const pending = this.pendingLists.get(key);
|
|
625
|
+
if (!pending || pending.next !== cursor) {
|
|
626
|
+
this.skip(x, "list-orphan-page", "tools/list");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
tools = [...pending.tools, ...result.tools];
|
|
630
|
+
}
|
|
631
|
+
this.pendingLists.delete(key);
|
|
632
|
+
if (next !== void 0) {
|
|
633
|
+
this.pendingLists.set(key, {
|
|
634
|
+
tools,
|
|
635
|
+
next,
|
|
636
|
+
testId: identity.testId
|
|
637
|
+
});
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const filepath = this.targetFile(identity);
|
|
641
|
+
const scope = scopeOf(identity);
|
|
642
|
+
if (!fileHasBlock(filepath, scope, x.mount)) {
|
|
643
|
+
this.completedLists.set(key, {
|
|
644
|
+
tools,
|
|
645
|
+
testId: identity.testId
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
let list;
|
|
650
|
+
try {
|
|
651
|
+
list = sanitizeRecording(tools, this.secrets(x), isMetaBranch);
|
|
652
|
+
} catch (err) {
|
|
653
|
+
this.failUnsafe(x, err);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
this.persist(x, filepath, scope, (block) => ({
|
|
657
|
+
block: withList(block ?? {}, list.value),
|
|
658
|
+
warnings: (prefix) => list.warnings.map((w) => `${prefix}/list${w}`)
|
|
659
|
+
}));
|
|
660
|
+
}
|
|
661
|
+
/** MR6 / MR7 / MR10-MR12: record one forwarded `tools/call`. */
|
|
662
|
+
recordCall(x, response, messages) {
|
|
663
|
+
const identity = x.identity;
|
|
664
|
+
const name = String(x.params?.name ?? "");
|
|
665
|
+
const result = cleanResult(response.message.result);
|
|
666
|
+
const entry = { args: x.params?.arguments ?? {} };
|
|
667
|
+
const errorText = errorTextOf(result);
|
|
668
|
+
if (errorText !== null) entry.error = errorText;
|
|
669
|
+
else entry.result = result;
|
|
670
|
+
const notifications = messages.filter((m) => m !== response && m.at <= response.at && !("id" in m.message) && typeof m.message.method === "string" && m.message.method.startsWith("notifications/")).map((m) => ({
|
|
671
|
+
atMs: Math.round(m.at),
|
|
672
|
+
method: m.message.method,
|
|
673
|
+
params: isObject(m.message.params) ? m.message.params : {}
|
|
674
|
+
}));
|
|
675
|
+
if (notifications.length > 0) entry.notifications = notifications;
|
|
676
|
+
entry.durationMs = Math.round(response.at);
|
|
677
|
+
const filepath = this.targetFile(identity);
|
|
678
|
+
const scope = scopeOf(identity);
|
|
679
|
+
const key = listKey(x.mount, identity);
|
|
680
|
+
const waitingList = this.completedLists.get(key);
|
|
681
|
+
const secrets = this.secrets(x);
|
|
682
|
+
let clean;
|
|
683
|
+
let recorded;
|
|
684
|
+
let list = null;
|
|
685
|
+
try {
|
|
686
|
+
clean = sanitizeRecording(entry, secrets, isMetaBranch);
|
|
687
|
+
recorded = sanitizeRecording(this.provenance(x, response.message), secrets, () => true);
|
|
688
|
+
if (waitingList) list = sanitizeRecording(waitingList.tools, secrets, isMetaBranch);
|
|
689
|
+
} catch (err) {
|
|
690
|
+
this.failUnsafe(x, err);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (!this.persist(x, filepath, scope, (block) => {
|
|
694
|
+
if (!block) return {
|
|
695
|
+
block: {
|
|
696
|
+
scope,
|
|
697
|
+
mount: x.mount,
|
|
698
|
+
...list ? { list: list.value } : {},
|
|
699
|
+
recorded: recorded.value,
|
|
700
|
+
tools: [{
|
|
701
|
+
name,
|
|
702
|
+
calls: [clean.value]
|
|
703
|
+
}]
|
|
704
|
+
},
|
|
705
|
+
warnings: (prefix) => [
|
|
706
|
+
...list ? list.warnings.map((w) => `${prefix}/list${w}`) : [],
|
|
707
|
+
...recorded.warnings.map((w) => `${prefix}/recorded${w}`),
|
|
708
|
+
...clean.warnings.map((w) => `${prefix}/tools/0/calls/0${w}`)
|
|
709
|
+
]
|
|
710
|
+
};
|
|
711
|
+
const tools = Array.isArray(block.tools) ? [...block.tools] : [];
|
|
712
|
+
let t = tools.findIndex((tool) => isObject(tool) && tool.name === name);
|
|
713
|
+
let c = 0;
|
|
714
|
+
if (t === -1) {
|
|
715
|
+
tools.push({
|
|
716
|
+
name,
|
|
717
|
+
calls: [clean.value]
|
|
718
|
+
});
|
|
719
|
+
t = tools.length - 1;
|
|
720
|
+
} else {
|
|
721
|
+
const tool = tools[t];
|
|
722
|
+
const calls = Array.isArray(tool.calls) ? [...tool.calls] : [];
|
|
723
|
+
calls.push(clean.value);
|
|
724
|
+
c = calls.length - 1;
|
|
725
|
+
tools[t] = {
|
|
726
|
+
...tool,
|
|
727
|
+
calls
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
block: list ? withList({
|
|
732
|
+
...block,
|
|
733
|
+
tools
|
|
734
|
+
}, list.value) : {
|
|
735
|
+
...block,
|
|
736
|
+
tools
|
|
737
|
+
},
|
|
738
|
+
warnings: (prefix) => [...list ? list.warnings.map((w) => `${prefix}/list${w}`) : [], ...clean.warnings.map((w) => `${prefix}/tools/${t}/calls/${c}${w}`)]
|
|
739
|
+
};
|
|
740
|
+
})) return;
|
|
741
|
+
if (waitingList) this.completedLists.delete(key);
|
|
742
|
+
const before = new Set(this.host.fakes.allEntryIds());
|
|
743
|
+
try {
|
|
744
|
+
this.host.addMcpFakes([{
|
|
745
|
+
source: "record",
|
|
746
|
+
blockIndex: null,
|
|
747
|
+
raw: {
|
|
748
|
+
scope,
|
|
749
|
+
mount: x.mount,
|
|
750
|
+
tools: [{
|
|
751
|
+
name,
|
|
752
|
+
calls: [clean.value]
|
|
753
|
+
}]
|
|
754
|
+
}
|
|
755
|
+
}], { kind: "record" });
|
|
756
|
+
} catch (err) {
|
|
757
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
758
|
+
this.log().error(build(msg`MCP-RECORD: the recorded entry was written but not registered on mount ${plainText(x.mount)}: ${plainText(detail)}`));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const added = this.host.fakes.allEntryIds().filter((id) => !before.has(id));
|
|
762
|
+
this.host.fakes.markConsumed(identity.testId, added);
|
|
763
|
+
}
|
|
764
|
+
/** FA2 `recorded` for a new block (MR6 3). */
|
|
765
|
+
provenance(x, response) {
|
|
766
|
+
const sessionId = headerOf(x.req, "mcp-session-id");
|
|
767
|
+
const session = sessionId ? this.sessions.get(sessionId) : void 0;
|
|
768
|
+
const meta = isObject(x.params?._meta) ? x.params._meta : void 0;
|
|
769
|
+
const result = isObject(response.result) ? response.result : void 0;
|
|
770
|
+
const resultMeta = isObject(result?._meta) ? result._meta : void 0;
|
|
771
|
+
const protocolVersion = session?.protocolVersion ?? (typeof meta?.[PROTOCOL_VERSION_META] === "string" ? meta[PROTOCOL_VERSION_META] : void 0) ?? headerOf(x.req, "mcp-protocol-version") ?? FALLBACK_VERSION;
|
|
772
|
+
const serverInfo = session?.serverInfo ?? (isObject(resultMeta?.[SERVER_INFO_META]) ? resultMeta[SERVER_INFO_META] : void 0);
|
|
773
|
+
return {
|
|
774
|
+
upstream: this.target.origin,
|
|
775
|
+
protocolVersion,
|
|
776
|
+
...serverInfo ? { serverInfo } : {},
|
|
777
|
+
aimockVersion: aimockVersion((m) => this.log().warn(m)),
|
|
778
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/** S2 (a)-(d): the known secrets of one request. */
|
|
782
|
+
secrets(x) {
|
|
783
|
+
const headers = {};
|
|
784
|
+
for (const [name, value] of Object.entries(x.req.headers)) if (value !== void 0) headers[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
785
|
+
return knownSecrets({
|
|
786
|
+
headers,
|
|
787
|
+
...this.auth ? { upstreamAuth: this.auth } : {},
|
|
788
|
+
upstreamUrl: this.upstreamUrl(x.req, x.subPath).href,
|
|
789
|
+
secretValues: this.secretValues
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/** S6 / RL1: an unsafe recording is not written. */
|
|
793
|
+
failUnsafe(x, err) {
|
|
794
|
+
if (!(err instanceof RecordUnsafeError)) throw err;
|
|
795
|
+
this.fail(x, "unsafe", err.reason);
|
|
796
|
+
}
|
|
797
|
+
/** RL1: a recording that was not written, with its reason (never a value). */
|
|
798
|
+
fail(x, reason, detail) {
|
|
799
|
+
x.recordSkipped = reason;
|
|
800
|
+
x.recordError = `${reason}: ${detail}`;
|
|
801
|
+
this.log().error(build(msg`MCP-RECORD: recording failed (${fixed(reason)}) for ${plainText(x.rpcMethod ?? "?")} on mount ${plainText(x.mount)}: ${plainText(detail)}`));
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* MR10 / MR11: merge one change into the block of `scope` and this mount
|
|
805
|
+
* (appending a block when none matches), validate the whole file, and write
|
|
806
|
+
* it. `apply` gets that block (`undefined` when it is new) and returns the
|
|
807
|
+
* new block and its sanitizer warnings under the block's pointer.
|
|
808
|
+
*/
|
|
809
|
+
persist(x, filepath, scope, apply) {
|
|
810
|
+
const result = persistServiceFakes({
|
|
811
|
+
filepath,
|
|
812
|
+
logger: this.log(),
|
|
813
|
+
merge: (existing) => mergeBlock(existing, scope, x.mount, apply),
|
|
814
|
+
validate: (content) => {
|
|
815
|
+
const first = validateMcpFakes(content.mcpFakes, filepath).errors[0];
|
|
816
|
+
if (!first) return null;
|
|
817
|
+
const where = first.entryId ?? first.blockId;
|
|
818
|
+
return where ? `${first.rule} (${where})` : first.rule;
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
if (result.kind === "failed") {
|
|
822
|
+
this.fail(x, result.stage === "write" ? "write-failed" : "invalid-merge", result.error);
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
const resolved = path.resolve(result.filepath);
|
|
826
|
+
this.hashes.set(resolved, result.sha256);
|
|
827
|
+
for (const listener of this.listeners) try {
|
|
828
|
+
listener(resolved, result.content.mcpFakes);
|
|
829
|
+
} catch (err) {
|
|
830
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
831
|
+
this.log().error(build(msg`MCP-RECORD: a write listener failed for ${plainText(resolved)} on mount ${plainText(x.mount)}: ${plainText(detail)}`));
|
|
832
|
+
}
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
/** MR10: the file a recording of `identity` goes to. */
|
|
836
|
+
targetFile(identity) {
|
|
837
|
+
const slug = identity.testId !== null ? slugifyTestId(identity.testId) : identity.context !== null ? slugifyContext(identity.context) : "";
|
|
838
|
+
if (slug) return path.join(this.fixturePath, slug, "mcp.json");
|
|
839
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
840
|
+
return path.join(this.fixturePath, `mcp-${timestamp}-${crypto$1.randomUUID().slice(0, 8)}.json`);
|
|
841
|
+
}
|
|
842
|
+
/** MR8 / RL2: mark the request forwarded-not-recorded and warn. */
|
|
843
|
+
skip(x, reason, method) {
|
|
844
|
+
x.recordSkipped = reason;
|
|
845
|
+
this.log().warn(build(msg`MCP-RECORD: forwarded, not recorded (${fixed(reason)}): ${plainText(method)} on mount ${plainText(x.mount)}`));
|
|
846
|
+
}
|
|
847
|
+
log() {
|
|
848
|
+
return this.host.logger() ?? this.fallbackLogger;
|
|
849
|
+
}
|
|
850
|
+
/** AM8: one journal entry per request. */
|
|
851
|
+
journal(x) {
|
|
852
|
+
const { req, res } = x;
|
|
853
|
+
this.host.journalEntry({
|
|
854
|
+
method: req.method ?? "POST",
|
|
855
|
+
path: req.url ?? "/",
|
|
856
|
+
headers: flattenHeaders(req.headers),
|
|
857
|
+
body: x.message,
|
|
858
|
+
service: "mcp",
|
|
859
|
+
...x.identity ? {
|
|
860
|
+
testId: x.identity.testId ?? MCP_FAKES_DEFAULT_TEST_ID,
|
|
861
|
+
context: x.identity.context
|
|
862
|
+
} : {},
|
|
863
|
+
response: {
|
|
864
|
+
status: res.statusCode,
|
|
865
|
+
fixture: null,
|
|
866
|
+
...x.proxied ? { source: "proxy" } : {},
|
|
867
|
+
...x.recordSkipped !== void 0 ? { recordSkipped: x.recordSkipped } : {},
|
|
868
|
+
...x.mcpFake ? { mcpFake: x.mcpFake } : {}
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
function isObject(value) {
|
|
874
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
875
|
+
}
|
|
876
|
+
/** One header's value (the first, when repeated), or undefined. */
|
|
877
|
+
function headerOf(req, name) {
|
|
878
|
+
const value = req.headers[name];
|
|
879
|
+
return Array.isArray(value) ? value[0] : value;
|
|
880
|
+
}
|
|
881
|
+
/** The JSON-RPC messages of one SSE event (its `data:` lines). */
|
|
882
|
+
function sseMessages(event) {
|
|
883
|
+
const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
|
|
884
|
+
if (data === "") return [];
|
|
885
|
+
try {
|
|
886
|
+
const parsed = JSON.parse(data);
|
|
887
|
+
return (Array.isArray(parsed) ? parsed : [parsed]).filter(isObject);
|
|
888
|
+
} catch {
|
|
889
|
+
return [];
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/** Whether `message` is the JSON-RPC response to request `id`. */
|
|
893
|
+
function isResponseTo(message, id) {
|
|
894
|
+
return id !== void 0 && message.id === id && ("result" in message || "error" in message);
|
|
895
|
+
}
|
|
896
|
+
/** The decoded name of one raw `name=value` query pair (form rules: `+` is a space). */
|
|
897
|
+
function queryName(pair) {
|
|
898
|
+
const eq = pair.indexOf("=");
|
|
899
|
+
const name = (eq === -1 ? pair : pair.slice(0, eq)).replace(/\+/g, " ");
|
|
900
|
+
try {
|
|
901
|
+
return decodeURIComponent(name);
|
|
902
|
+
} catch {
|
|
903
|
+
return name;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function mountOfKey(key) {
|
|
907
|
+
return key.slice(0, key.indexOf("\0"));
|
|
908
|
+
}
|
|
909
|
+
/** MR8: why a forwarded `tools/call` response is not recorded, or null. */
|
|
910
|
+
function callSkipReason(response, messages) {
|
|
911
|
+
if ("error" in response) return "upstream-error";
|
|
912
|
+
const result = response.result;
|
|
913
|
+
if (!isObject(result)) return "upstream-error";
|
|
914
|
+
if (result.resultType === "input_required") return "input-required";
|
|
915
|
+
if (result.task !== void 0 || isObject(result._meta) && RELATED_TASK_META in result._meta) return "task-handle";
|
|
916
|
+
if (messages.some((m) => typeof m.message.method === "string" && "id" in m.message)) return "server-request";
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
/** MR7: a recorded result without trace `_meta` keys, serverInfo and `resultType: "complete"`. */
|
|
920
|
+
function cleanResult(result) {
|
|
921
|
+
const out = { ...result };
|
|
922
|
+
if (out.resultType === "complete") delete out.resultType;
|
|
923
|
+
if (isObject(out._meta)) {
|
|
924
|
+
const meta = { ...out._meta };
|
|
925
|
+
for (const key of DROPPED_META) delete meta[key];
|
|
926
|
+
if (Object.keys(meta).length > 0) out._meta = meta;
|
|
927
|
+
else delete out._meta;
|
|
928
|
+
}
|
|
929
|
+
return out;
|
|
930
|
+
}
|
|
931
|
+
/** MR6: the `error` text of an `isError` result that is exactly one text block, else null. */
|
|
932
|
+
function errorTextOf(result) {
|
|
933
|
+
if (result.isError !== true) return null;
|
|
934
|
+
if (!Object.keys(result).every((k) => k === "content" || k === "isError")) return null;
|
|
935
|
+
const content = result.content;
|
|
936
|
+
if (!Array.isArray(content) || content.length !== 1) return null;
|
|
937
|
+
const block = content[0];
|
|
938
|
+
if (!isObject(block) || block.type !== "text" || typeof block.text !== "string") return null;
|
|
939
|
+
return Object.keys(block).length === 2 ? block.text : null;
|
|
940
|
+
}
|
|
941
|
+
/** S4: credential-shaped keys are removed only inside `_meta`. */
|
|
942
|
+
function isMetaBranch(pointer) {
|
|
943
|
+
return /(?:^|\/)_meta(?:\/|$)/.test(pointer);
|
|
944
|
+
}
|
|
945
|
+
/** MR10 block scope: the test id, plus the context when there is one. */
|
|
946
|
+
function scopeOf(identity) {
|
|
947
|
+
if (identity.testId === null) return { context: identity.context ?? "" };
|
|
948
|
+
return identity.context === null ? { testId: identity.testId } : {
|
|
949
|
+
testId: identity.testId,
|
|
950
|
+
context: identity.context
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/** MR10: a block scope exactly equal to `scope`. */
|
|
954
|
+
function sameScope(raw, scope) {
|
|
955
|
+
if (!isObject(raw)) return false;
|
|
956
|
+
const keys = Object.keys(scope);
|
|
957
|
+
return Object.keys(raw).length === keys.length && keys.every((k) => raw[k] === scope[k]);
|
|
958
|
+
}
|
|
959
|
+
/** MR16 pending-list key: mount and scope. */
|
|
960
|
+
function listKey(mount, identity) {
|
|
961
|
+
return `${mount}\u0000${JSON.stringify([identity.testId, identity.context])}`;
|
|
962
|
+
}
|
|
963
|
+
/** Whether the file at `filepath` already has the block of `scope` and `mount`. */
|
|
964
|
+
function fileHasBlock(filepath, scope, mount) {
|
|
965
|
+
let parsed;
|
|
966
|
+
try {
|
|
967
|
+
parsed = JSON.parse(fs.readFileSync(filepath, "utf-8"));
|
|
968
|
+
} catch {
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
971
|
+
if (!isObject(parsed)) return false;
|
|
972
|
+
const raw = parsed.mcpFakes;
|
|
973
|
+
return (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).some((b) => isObject(b) && sameScope(b.scope, scope) && b.mount === mount);
|
|
974
|
+
}
|
|
975
|
+
/** MR10 exception: a block with `list` replaced (or inserted after `mount`), key order kept. */
|
|
976
|
+
function withList(block, list) {
|
|
977
|
+
if ("list" in block) return {
|
|
978
|
+
...block,
|
|
979
|
+
list
|
|
980
|
+
};
|
|
981
|
+
const out = {};
|
|
982
|
+
let placed = false;
|
|
983
|
+
for (const [key, value] of Object.entries(block)) {
|
|
984
|
+
out[key] = value;
|
|
985
|
+
if (key === "mount") {
|
|
986
|
+
out.list = list;
|
|
987
|
+
placed = true;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (!placed) out.list = list;
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* MR10 append rule over a whole file: the first block whose scope and mount
|
|
995
|
+
* are exactly equal is changed by `apply`; otherwise a block is appended.
|
|
996
|
+
* Object form becomes array form only when a second block is needed. Other
|
|
997
|
+
* top-level keys are kept (fakes:F9); `_warnings` gains the new pointers.
|
|
998
|
+
*/
|
|
999
|
+
function mergeBlock(existing, scope, mount, apply) {
|
|
1000
|
+
const content = existing ? { ...existing } : {};
|
|
1001
|
+
const raw = content.mcpFakes;
|
|
1002
|
+
const blocks = Array.isArray(raw) ? [...raw] : raw === void 0 ? [] : [raw];
|
|
1003
|
+
let index = blocks.findIndex((b) => isObject(b) && sameScope(b.scope, scope) && b.mount === mount);
|
|
1004
|
+
const applied = apply(index === -1 ? void 0 : blocks[index]);
|
|
1005
|
+
if (index === -1) {
|
|
1006
|
+
blocks.push(applied.block);
|
|
1007
|
+
index = blocks.length - 1;
|
|
1008
|
+
} else blocks[index] = applied.block;
|
|
1009
|
+
const arrayForm = Array.isArray(raw) || blocks.length > 1;
|
|
1010
|
+
content.mcpFakes = arrayForm ? blocks : blocks[0];
|
|
1011
|
+
let warnings = Array.isArray(content._warnings) ? content._warnings.filter((w) => typeof w === "string") : [];
|
|
1012
|
+
if (isObject(raw) && arrayForm) warnings = warnings.map((w) => /^\/mcpFakes(?=[/:])/.test(w) ? `/mcpFakes/0${w.slice(9)}` : w);
|
|
1013
|
+
const added = applied.warnings(arrayForm ? `/mcpFakes/${index}` : "/mcpFakes");
|
|
1014
|
+
if (warnings.length > 0 || added.length > 0) content._warnings = [...new Set([...warnings, ...added])];
|
|
1015
|
+
return content;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* A mount key as an error message may show it: JSON-quoted, with any
|
|
1019
|
+
* credential (URL userinfo, a credential query parameter, a secret-looking
|
|
1020
|
+
* fragment) removed by the record sanitizer's URL rule (S2 c) and marked as
|
|
1021
|
+
* removed. A key with an `@` that the URL rule cannot parse is not shown.
|
|
1022
|
+
*/
|
|
1023
|
+
function redactMountForError(mount) {
|
|
1024
|
+
const prefix = /^[a-z][a-z0-9+.-]*:\/\//i.test(mount) ? "" : mount.startsWith("/") ? "http://localhost" : "http://";
|
|
1025
|
+
const scrubbed = scrubUrl(prefix + mount, () => false, () => {
|
|
1026
|
+
throw new Error("unreachable: no value is a secret here");
|
|
1027
|
+
});
|
|
1028
|
+
if (scrubbed !== prefix + mount) {
|
|
1029
|
+
const shown = scrubbed.startsWith(prefix) ? scrubbed.slice(prefix.length) : REDACTED;
|
|
1030
|
+
return `${JSON.stringify(shown)} (credentials removed)`;
|
|
1031
|
+
}
|
|
1032
|
+
return mount.includes("@") && !URL.canParse(prefix + mount) ? JSON.stringify(REDACTED) : JSON.stringify(mount);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Why a mount path can never be reached, or `undefined` when it can. The
|
|
1036
|
+
* server matches a request's URL pathname against the mount exactly or as a
|
|
1037
|
+
* `<mount>/` prefix, so a mount must be a plain, normalized URL path: it
|
|
1038
|
+
* starts with one `/`, has no trailing `/` (except the root `/`), and has no
|
|
1039
|
+
* query, fragment, whitespace, dot segment or character the URL parser escapes.
|
|
1040
|
+
*/
|
|
1041
|
+
function unreachableMountReason(mount) {
|
|
1042
|
+
if (!mount.startsWith("/")) return "it must start with \"/\"";
|
|
1043
|
+
if (mount.startsWith("//")) return "it must not start with \"//\"";
|
|
1044
|
+
if (mount.length > 1 && mount.endsWith("/")) return "it must not end with \"/\"";
|
|
1045
|
+
if (new URL(mount, "http://localhost").pathname !== mount) return "it must be a plain URL path, with no query, fragment, whitespace, dot segment or escaped character";
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Why an upstream URL is unusable, or `undefined` when it is a valid http(s)
|
|
1049
|
+
* URL. Never includes the value: it can carry credentials (S2 c).
|
|
1050
|
+
*/
|
|
1051
|
+
function badUpstreamReason(upstream) {
|
|
1052
|
+
let url;
|
|
1053
|
+
try {
|
|
1054
|
+
url = new URL(upstream);
|
|
1055
|
+
} catch {
|
|
1056
|
+
return "is not a valid URL";
|
|
1057
|
+
}
|
|
1058
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return "must be an http: or https: URL";
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* F2: fail at start, not at every write, when the recording destination can
|
|
1062
|
+
* never be written. The deepest path component that exists must be a
|
|
1063
|
+
* directory this process can write to and enter. A path that does not exist
|
|
1064
|
+
* yet is created on the first write, but not through a broken symbolic link.
|
|
1065
|
+
* `label` prefixes the error (for example the mount it belongs to).
|
|
1066
|
+
*/
|
|
1067
|
+
function checkMcpRecordDestination(fixturePath, label) {
|
|
1068
|
+
const destination = path.resolve(fixturePath);
|
|
1069
|
+
const fail = (problem) => {
|
|
1070
|
+
const text = `MCP record destination ${destination} ${problem}`;
|
|
1071
|
+
throw new Error(label ? `${label}: ${text}` : text);
|
|
1072
|
+
};
|
|
1073
|
+
const code = (err) => err?.code ?? String(err);
|
|
1074
|
+
let probe = destination;
|
|
1075
|
+
for (;;) {
|
|
1076
|
+
let stat;
|
|
1077
|
+
let broken = false;
|
|
1078
|
+
try {
|
|
1079
|
+
const entry = fs.lstatSync(probe, { throwIfNoEntry: false });
|
|
1080
|
+
stat = entry?.isSymbolicLink() ? fs.statSync(probe, { throwIfNoEntry: false }) : entry;
|
|
1081
|
+
broken = entry !== void 0 && stat === void 0;
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
if (code(err) !== "ENOTDIR") fail(`cannot be checked (${code(err)} at ${probe})`);
|
|
1084
|
+
}
|
|
1085
|
+
if (broken) fail(`goes through a broken symbolic link at ${probe}`);
|
|
1086
|
+
if (stat !== void 0) {
|
|
1087
|
+
if (!stat.isDirectory()) fail("is not a directory");
|
|
1088
|
+
try {
|
|
1089
|
+
fs.accessSync(probe, fs.constants.W_OK | fs.constants.X_OK);
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
fail(`is not writable (${code(err)} at ${probe})`);
|
|
1092
|
+
}
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
const parent = path.dirname(probe);
|
|
1096
|
+
if (parent === probe) return;
|
|
1097
|
+
probe = parent;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Parse a `--mcp-record` / `--mcp-proxy-only` value at its first `=`. The
|
|
1102
|
+
* mount must be a reachable path and the upstream an http(s) URL. A bad value
|
|
1103
|
+
* throws an error that names `flag`, never the URL (it can carry credentials).
|
|
1104
|
+
*/
|
|
1105
|
+
function parseMcpRecordFlag(value, flag = "--mcp-record") {
|
|
1106
|
+
const eq = value.indexOf("=");
|
|
1107
|
+
const mount = eq === -1 ? "" : value.slice(0, eq);
|
|
1108
|
+
const upstream = eq === -1 ? "" : value.slice(eq + 1);
|
|
1109
|
+
if (!mount.startsWith("/")) throw new Error(`${flag} must be <mount>=<url>, with a mount that starts with "/"`);
|
|
1110
|
+
const unreachable = unreachableMountReason(mount);
|
|
1111
|
+
if (unreachable !== void 0) throw new Error(`${flag} mount ${redactMountForError(mount)} can never be reached: ${unreachable}`);
|
|
1112
|
+
const badUpstream = badUpstreamReason(upstream);
|
|
1113
|
+
if (badUpstream !== void 0) throw new Error(`${flag} ${mount}: the upstream ${badUpstream}`);
|
|
1114
|
+
return {
|
|
1115
|
+
mount,
|
|
1116
|
+
upstream
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* AM6 / S2 (d): the recorder settings that come from the environment.
|
|
1121
|
+
* `AIMOCK_RECORD_SECRET_VALUES` is newline-separated (LF or CRLF); empty
|
|
1122
|
+
* entries are dropped and nothing else is trimmed. A malformed
|
|
1123
|
+
* `AIMOCK_MCP_UPSTREAM_AUTH` throws an error that names the variable.
|
|
1124
|
+
*/
|
|
1125
|
+
function mcpRecordEnv(env) {
|
|
1126
|
+
const upstreamAuth = env.AIMOCK_MCP_UPSTREAM_AUTH;
|
|
1127
|
+
parseUpstreamAuth(upstreamAuth);
|
|
1128
|
+
return {
|
|
1129
|
+
secretValues: (env.AIMOCK_RECORD_SECRET_VALUES ?? "").split(/\r?\n/).filter(Boolean),
|
|
1130
|
+
upstreamAuth: upstreamAuth === "" ? void 0 : upstreamAuth
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* MR1: `llm.record.mcp` — record each mount path. A path the caller mounted
|
|
1135
|
+
* with an `MCPMock` records through it; a path held by another kind of mount
|
|
1136
|
+
* is a start error; a free path gets a new `MCPMock`, mounted on `llmock`.
|
|
1137
|
+
* A string value is `{ upstream }`. `secretValues` and `upstreamAuth` come
|
|
1138
|
+
* from the value, else from the environment, as on the `llmock` CLI.
|
|
1139
|
+
*/
|
|
1140
|
+
function wireMcpRecording(llmock, record, opts) {
|
|
1141
|
+
const where = "llm.record.mcp";
|
|
1142
|
+
const plain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1143
|
+
const keyTypes = {
|
|
1144
|
+
upstream: "a string",
|
|
1145
|
+
fixturePath: "a non-empty string",
|
|
1146
|
+
proxyOnly: "a boolean",
|
|
1147
|
+
maxRecordBufferBytes: `a finite number greater than 0 and at most ${MAX_RECORD_BUFFER_BYTES}`,
|
|
1148
|
+
upstreamAuth: "a string",
|
|
1149
|
+
secretValues: "an array of strings",
|
|
1150
|
+
strict: "a boolean"
|
|
1151
|
+
};
|
|
1152
|
+
const hasType = (key, v) => {
|
|
1153
|
+
switch (key) {
|
|
1154
|
+
case "proxyOnly":
|
|
1155
|
+
case "strict": return typeof v === "boolean";
|
|
1156
|
+
case "maxRecordBufferBytes": return typeof v === "number" && Number.isFinite(v) && v > 0 && v <= MAX_RECORD_BUFFER_BYTES;
|
|
1157
|
+
case "secretValues": return Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
1158
|
+
case "fixturePath": return typeof v === "string" && v.length > 0;
|
|
1159
|
+
default: return typeof v === "string";
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
const isConfigKey = (key) => Object.prototype.hasOwnProperty.call(keyTypes, key);
|
|
1163
|
+
const unknownRecord = record;
|
|
1164
|
+
if (!plain(unknownRecord)) throw new Error(`${where} must be an object of <mount>: <url or record settings>`);
|
|
1165
|
+
const sharedFixturePath = opts.fixturePath;
|
|
1166
|
+
if (sharedFixturePath !== void 0 && !(typeof sharedFixturePath === "string" && sharedFixturePath.length > 0)) throw new Error("llm.record.fixturePath must be a non-empty string");
|
|
1167
|
+
const wired = [];
|
|
1168
|
+
for (const [mountPath, value] of Object.entries(unknownRecord)) {
|
|
1169
|
+
const mount = redactMountForError(mountPath);
|
|
1170
|
+
if (!mountPath.startsWith("/")) throw new Error(`${where} must be <mount>: <url or object>, with a mount that starts with "/"; got ${mount}`);
|
|
1171
|
+
const unreachable = unreachableMountReason(mountPath);
|
|
1172
|
+
if (unreachable !== void 0) throw new Error(`${where} mount ${mount} can never be reached: ${unreachable}`);
|
|
1173
|
+
if (typeof value !== "string" && !plain(value)) throw new Error(`${where} mount ${mount} must be an upstream URL string or an object`);
|
|
1174
|
+
if (typeof value !== "string") {
|
|
1175
|
+
for (const [key, v] of Object.entries(value)) {
|
|
1176
|
+
if (!isConfigKey(key)) throw new Error(`${where} mount ${mount} has an unknown key ${JSON.stringify(key)} (expected one of ${Object.keys(keyTypes).join(", ")})`);
|
|
1177
|
+
if (!hasType(key, v)) throw new Error(`${where} mount ${mount} key ${key} must be ${keyTypes[key]}`);
|
|
1178
|
+
}
|
|
1179
|
+
if (!("upstream" in value)) throw new Error(`${where} mount ${mount} key upstream is required`);
|
|
1180
|
+
}
|
|
1181
|
+
const typed = record[mountPath];
|
|
1182
|
+
const config = typeof typed === "string" ? { upstream: typed } : typed;
|
|
1183
|
+
const badUpstream = badUpstreamReason(config.upstream);
|
|
1184
|
+
if (badUpstream !== void 0) throw new Error(`${where} mount ${mount}: the upstream ${badUpstream}`);
|
|
1185
|
+
const held = opts.mounted.get(mountPath);
|
|
1186
|
+
if (held !== void 0 && !(held instanceof opts.mcpMock)) throw new Error(`llm.record.mcp mount ${mountPath} is held by a mount that is not an MCP mock`);
|
|
1187
|
+
const fixturePath = config.fixturePath ?? opts.fixturePath ?? (opts.fixtures ? path.resolve(opts.fixtures, "recorded") : void 0);
|
|
1188
|
+
if (!config.proxyOnly) {
|
|
1189
|
+
if (fixturePath === void 0) throw new Error("llm.record.mcp requires llm.fixtures or llm.record.fixturePath for the recording destination");
|
|
1190
|
+
checkMcpRecordDestination(fixturePath, `${where} mount ${mount}`);
|
|
1191
|
+
}
|
|
1192
|
+
wired.push({
|
|
1193
|
+
mountPath,
|
|
1194
|
+
config,
|
|
1195
|
+
fixturePath,
|
|
1196
|
+
held
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
const fromEnv = mcpRecordEnv(opts.env);
|
|
1200
|
+
for (const { mountPath, config, fixturePath, held } of wired) {
|
|
1201
|
+
const mcp = held ?? new opts.mcpMock();
|
|
1202
|
+
mcp.enableRecording({
|
|
1203
|
+
...config,
|
|
1204
|
+
fixturePath,
|
|
1205
|
+
secretValues: config.secretValues ?? fromEnv.secretValues,
|
|
1206
|
+
upstreamAuth: config.upstreamAuth ?? fromEnv.upstreamAuth,
|
|
1207
|
+
strict: config.strict ?? opts.strict
|
|
1208
|
+
});
|
|
1209
|
+
if (held === void 0) llmock.mount(mountPath, mcp);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
//#endregion
|
|
1214
|
+
export { McpRecorder, mcpRecordEnv, parseMcpRecordFlag, wireMcpRecording };
|
|
1215
|
+
//# sourceMappingURL=mcp-recorder.js.map
|