-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathjsonUtils.ts
More file actions
434 lines (409 loc) · 16.8 KB
/
Copy pathjsonUtils.ts
File metadata and controls
434 lines (409 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
import type { Tool } from "@modelcontextprotocol/client";
import { normalizeNullableUnion } from "./nullableUnion.js";
import {
narrowBySuppliedNames,
sameJsonValue,
resolveRootUnion,
type RootUnionBranch,
type RootUnionSchema,
} from "./rootUnion.js";
/**
* JSON value type used across the inspector project
*/
export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
/**
* A JSON value that survives serialization unchanged — {@link JsonValue}
* without `undefined`.
*
* `JsonValue` admits `undefined` because it is also used for *arguments*, where
* it usefully means "this field was not supplied". That meaning does not carry
* to a payload the Inspector promises to transmit verbatim: `JSON.stringify`
* drops an `undefined` object member entirely and rewrites an `undefined` array
* element to `null`, so a value the type accepted is not the value that arrives.
*
* Used for `_meta` (#1910), whose whole contract is that any JSON the user
* writes reaches the wire as written.
*/
export type StrictJsonValue =
| string
| number
| boolean
| null
| StrictJsonValue[]
| { [key: string]: StrictJsonValue };
export type StrictJsonObject = { [key: string]: StrictJsonValue };
/**
* Whether a value is JSON that survives `JSON.stringify` unchanged.
*
* {@link StrictJsonValue} is the *type*-level half of this and cannot express
* the rest: TypeScript has one `number`, but JSON has no encoding for `NaN` or
* `±Infinity`, so `JSON.stringify` rewrites them to `null`.
*
* That is reachable from ordinary input, not just from a careless caller —
* `JSON.parse("1e400")` returns `Infinity` on overflow, so text a user typed
* parses successfully, satisfies the type, and then reaches the wire as `null`
* while the editor still shows what they wrote. Checking at the boundary is the
* only place the distinction exists.
*/
export function isSerializableJson(value: unknown): value is StrictJsonValue {
if (value === null) return true;
switch (typeof value) {
case "string":
case "boolean":
return true;
case "number":
// Rejects NaN and ±Infinity; every other number round-trips.
return Number.isFinite(value);
case "object":
return Array.isArray(value)
? value.every(isSerializableJson)
: Object.values(value as Record<string, unknown>).every(
isSerializableJson,
);
default:
// `undefined`, functions, symbols and bigints are not JSON at all.
return false;
}
}
/**
* Widen a typed object to a generic string-keyed record so its keys can be
* iterated or read/written generically. Many of the project's config/SDK types
* (`StoredMCPServer`, `MCPServerConfig`, `pino.Logger`, DOM `Window`, …) have no
* index signature, so a direct `value as Record<string, unknown>` at a call
* site is a TS2352 error that would otherwise force an `as unknown as` double
* cast. Taking the argument as the general `object` type makes the single `as`
* legal — `Record<string, unknown>` is assignable to `object`, so the two types
* sufficiently overlap — letting this one audited spot own the widening while
* the double casts stay out of the call sites. Purely a structural view of the
* same object; no runtime effect.
*/
export function toRecord(value: object): Record<string, unknown> {
return value as Record<string, unknown>;
}
/**
* Simple schema type for parameter conversion
*/
type ParameterSchema = {
type?: string;
};
/**
* Convert a string parameter value to the appropriate JSON type based on schema
*/
export function convertParameterValue(
value: string,
schema: ParameterSchema,
): JsonValue {
if (!value) {
return value;
}
if (schema.type === "number" || schema.type === "integer") {
return Number(value);
}
if (schema.type === "boolean") {
return value.toLowerCase() === "true";
}
if (schema.type === "object" || schema.type === "array") {
try {
return JSON.parse(value) as JsonValue;
} catch {
return value;
}
}
return value;
}
/**
* Which property schema types each supplied argument, for a tool whose
* `inputSchema` puts its fields on root composition branches (#2123).
*
* Reading only the root's `properties` finds no schema for any of them, so
* every value would be sent as the string the user typed — `--tool-arg count=3`
* reaching the server as `"3"`.
*
* The CLI has no branch picker, so which branch a call means is *inferred*:
* a discriminated union pins its discriminator with `const`, and the supplied
* arguments either match one branch's constants or they do not.
*
* - **Exactly one branch matches** — use its merged schema, which is also where
* a branch's specialization of a root-declared property lives.
* - **No branch is identifiable** — coerce only the names every branch that
* declares them types the *same* way. A name two branches type differently
* is left uncoerced rather than coerced by an arbitrary branch: `value` as a
* number in branch 0 and a boolean in branch 1 would otherwise turn
* `value=true` into `Number("true")`, i.e. `NaN`. Passing the raw string
* through is what this function did for every argument before it existed.
*/
function coercionProperties<T extends RootUnionSchema>(
base: T,
branches: RootUnionBranch<T>[],
params: Record<string, string>,
): Record<string, unknown> {
if (branches.length === 0) {
return { ...base.properties };
}
// Which branch the call means, from the constants it supplies and then — when
// those leave more than one standing — from the argument NAMES it supplies,
// through the same narrowing the form uses. Without the second step an
// undiscriminated union whose branches type a shared name differently loses
// the coercion for every argument in it, including the ones that identify
// the branch unambiguously.
const candidates = branches
.map((branch, index) => ({ branch, index }))
.filter(({ branch }) =>
matchesConstants(branch.schema.properties ?? {}, params),
)
.map(({ index }) => index);
const selected = narrowBySuppliedNames(
branches,
candidates,
Object.keys(params),
);
if (selected !== null) {
return { ...branches[selected]!.schema.properties };
}
// `hasOwn`/`fromEntries` rather than `in`/assignment throughout: `properties`
// is a JSON record, so `constructor` and `__proto__` are legal argument names
// that the prototype chain and the legacy setter would otherwise mishandle.
const properties: Record<string, unknown> = Object.fromEntries(
Object.entries(base.properties ?? {}),
);
const pool =
candidates.length > 0 ? candidates.map((i) => branches[i]!) : branches;
for (const name of new Set(pool.flatMap((b) => b.declaredFields))) {
// Only the branches that *declare* the name have an opinion about it — a
// branch that merely inherited the root's declaration is not a second,
// disagreeing vote. Read through the merged schema so a branch's
// specialization of a root property carries the root's keywords too.
const declarations = pool
.filter((branch) => branch.declaredFields.includes(name))
.map((branch) => branch.schema.properties?.[name])
// A malformed declaration (`properties: { x: null }`) is not a vote about
// the type, and storing it as the coercion schema would put a value that
// is not a schema where one is expected.
.filter((schema) => typeof schema === "object" && schema !== null)
// Collapsed BEFORE the vote: a nullable declaration states its real type
// on the surviving branch, so `number | null` and `boolean | null` would
// otherwise both read as "no type" and be counted as agreeing — and the
// first would then coerce `value=true` to `NaN`.
.map((schema) => normalizeNullableUnion(schema as object));
const types = new Set(declarations.map((schema) => typeNameOf(schema)));
// The `const` has to agree too, not just the type: `{ const: 1 }` and
// `{ const: "1" }` both state no `type` and both match the text `1`, so
// agreeing on the type alone would send whichever typed constant came
// first rather than falling back to the raw string the user typed.
const pinnedOf = (schema: unknown) =>
typeof schema === "object" && schema !== null && "const" in schema
? (schema as { const?: unknown }).const
: undefined;
const constsAgree = declarations.every((schema) =>
sameJsonValue(pinnedOf(schema), pinnedOf(declarations[0])),
);
if (types.size === 1 && constsAgree && declarations.length > 0) {
Object.defineProperty(properties, name, {
value: declarations[0],
writable: true,
enumerable: true,
configurable: true,
});
} else {
delete properties[name];
}
}
return properties;
}
/** A schema's `type`, as a comparable string (`""` when it states none). */
function typeNameOf(schema: unknown): string {
if (typeof schema !== "object" || schema === null) return "";
const { type } = schema as { type?: unknown };
// Sorted: JSON Schema reads an array `type` as a SET, so `["number","null"]`
// and `["null","number"]` are the same declaration and must not read as a
// disagreement that drops the property from the coercion map.
return Array.isArray(type)
? [...type].map(String).sort().join(",")
: typeof type === "string"
? type
: "";
}
/**
* Whether every `const`-pinned property of a branch agrees with what was
* supplied. Values arrive as strings, so the comparison is stringified — which
* is exactly right for a discriminator, whose constants are string literals.
*/
function matchesConstants(
properties: Record<string, unknown>,
params: Record<string, string>,
): boolean {
return Object.entries(properties).every(([name, schema]) => {
if (typeof schema !== "object" || schema === null) return true;
const constValue = (schema as { const?: unknown }).const;
if (constValue === undefined) return true;
// `hasOwn`: an absent argument legally named `constructor` would otherwise
// read the inherited one and rule out every branch that pins that name.
if (!Object.hasOwn(params, name)) return true;
const supplied = params[name];
return supplied === undefined || suppliedMatchesConst(supplied, constValue);
});
}
/**
* Whether the text a CLI argument carries is the value a `const` fixes.
*
* A primitive constant is compared as text, which is all a command line has. A
* structured one — a `const` may be an object or an array, with or without a
* `type` — is parsed first: `String({...})` is `"[object Object]"`, which no
* argument can equal, so the only value the schema accepts would never match.
*/
function suppliedMatchesConst(value: string, constValue: unknown): boolean {
if (constValue === null || typeof constValue !== "object") {
return value === String(constValue);
}
try {
return sameJsonValue(JSON.parse(value), constValue);
} catch {
return false;
}
}
/**
* Convert string parameters to JSON values based on tool schema
*/
export function convertToolParameters(
tool: Tool,
params: Record<string, string>,
): Record<string, JsonValue> {
return convertParametersForSchema(tool.inputSchema, params);
}
/**
* {@link convertToolParameters} against a bare `inputSchema` rather than a
* `Tool`.
*
* Split out because the two callers hold different things: the client has the
* `Tool`, while a form holds only the schema it is rendering (which is that
* same object, structurally narrowed). Both must reach the SAME conversion —
* one of them decides what goes on the wire and the other decides whether to
* let the user send it, so a second implementation would let them disagree
* about which values are convertible.
*/
export function convertParametersForSchema(
inputSchema: unknown,
params: Record<string, string>,
): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
// A property's schema can live on a root composition branch rather than on
// the root itself (#2123); see `coercionProperties` for how the branch is
// identified when it does.
const { base, branches } = resolveRootUnion(
(inputSchema ?? {}) as RootUnionSchema,
);
const properties = coercionProperties(base, branches, params);
for (const [key, value] of Object.entries(params)) {
const declared = properties[key];
// Collapsed first: a nullable declaration (`type: ["number","null"]`, or an
// `anyOf` with a null branch) states its real type on the surviving branch,
// and `convertParameterValue` dispatches on a single `type` string — so
// without this a nullable number is sent as the string it was typed as.
const paramSchema =
typeof declared === "object" && declared !== null
? (normalizeNullableUnion(declared) as ParameterSchema)
: (declared as ParameterSchema | undefined);
// A `const` the supplied text names is sent as the schema's own typed
// value, not as the text: a branch pinned to `const: 2` is selected by
// `kind=2` and would otherwise be sent `"2"`, which that same branch
// rejects. Only an exact match is substituted — anything else is the
// user's input and is left alone.
const pinned = (paramSchema as { const?: unknown } | undefined)?.const;
const converted =
pinned !== undefined && suppliedMatchesConst(value, pinned)
? (pinned as JsonValue)
: paramSchema
? convertParameterValue(value, paramSchema)
: value;
// `defineProperty`, not assignment: `__proto__` is a legal argument name —
// a discriminator can carry it — and assigning it would invoke the legacy
// prototype setter instead of putting it in the call.
Object.defineProperty(result, key, {
value: converted,
writable: true,
enumerable: true,
configurable: true,
});
}
return result;
}
/**
* The argument names a `tools/call` would silently retype, per the tool's
* schema — empty when every value is already the type the schema declares.
*
* Runs the conversion the client runs and compares, rather than restating its
* rules: {@link convertParametersForSchema} is the function on the other side,
* so asking it is the only way a caller cannot drift from what is actually
* sent.
*
* Only string-valued arguments can be retyped, because that is all the
* conversion looks at — a JSON draft that already writes `2` as a number is
* passed through untouched, and is not reported here.
*
* Used by the two places a payload is authored as JSON rather than through
* widgets — the Tools/Apps form's "Edit as JSON" switch and the
* Edit-and-replay modal — each of which refuses a draft this returns anything
* for, so what the editor shows is what the wire carries (#2171).
*/
export function coercedArgumentNames(
inputSchema: unknown,
args: Record<string, unknown>,
): string[] {
const stringArgs: Record<string, string> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") stringArgs[key] = value;
}
if (Object.keys(stringArgs).length === 0) return [];
const converted = convertParametersForSchema(inputSchema, stringArgs);
// `Object.keys` of the *supplied* names, so an argument the schema does not
// declare (which the conversion passes through) cannot be reported.
return Object.keys(stringArgs).filter(
(key) => converted[key] !== stringArgs[key],
);
}
/**
* The refusal {@link coercedArgumentNames} justifies, as one sentence.
*
* Shared for the same reason the check is: the Tools/Apps raw-JSON editor and
* the Edit-and-replay modal refuse the same drafts, so they must not word it
* two different ways. `toolName` is included when the caller knows which tool
* the draft targets — the replay modal does, because its `name` is editable
* and the schema is looked up from it; a form is already rendering one tool
* and would only be repeating itself.
*/
export function coercedArgumentsError(
names: string[],
toolName?: string,
): string {
const quoted = names.map((name) => `\`${name}\``).join(", ");
const whose = toolName
? `the type ${toolName}'s schema declares`
: "the type the schema declares";
return `${quoted} would be converted to ${whose} — write the value with that type instead`;
}
/**
* Convert prompt arguments (JsonValue) to strings for prompt API
*/
export function convertPromptArguments(
args: Record<string, JsonValue>,
): Record<string, string> {
const stringArgs: Record<string, string> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") {
stringArgs[key] = value;
} else if (value === null || value === undefined) {
stringArgs[key] = String(value);
} else {
stringArgs[key] = JSON.stringify(value);
}
}
return stringArgs;
}