@modelcontextprotocol/inspector-cli 1.0.3 → 1.0.4

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/build/index.js CHANGED
@@ -6,6 +6,7 @@ import { callTool, connect, disconnect, getPrompt, listPrompts, listResources, l
6
6
  import { handleError } from "./error-handler.js";
7
7
  import { createTransport } from "./transport.js";
8
8
  import { awaitableLog } from "./utils/awaitable-log.js";
9
+ import { RegexFreeJsonSchemaValidator } from "./utils/regexFreeSchemaValidator.js";
9
10
  function createTransportOptions(target, transport, headers) {
10
11
  if (target.length === 0) {
11
12
  throw new Error("Target is required. Specify a URL or a command to execute.");
@@ -65,7 +66,12 @@ async function callMethod(args) {
65
66
  const [, name = packageJson.name] = packageJson.name.split("/");
66
67
  const version = packageJson.version;
67
68
  const clientIdentity = { name, version };
68
- const client = new Client(clientIdentity);
69
+ // Tool output schemas come from the server; the SDK validates results
70
+ // against them, so give it a provider that never runs their regexes on the
71
+ // native backtracking RegExp engine (skipped patterns are noted on stderr).
72
+ const client = new Client(clientIdentity, {
73
+ jsonSchemaValidator: new RegexFreeJsonSchemaValidator((note) => console.error(`Warning: ${note}`)),
74
+ });
69
75
  try {
70
76
  await connect(client, transport);
71
77
  let result;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The JSON Schema validator provider the CLI hands the SDK `Client`, which
3
+ * uses it to validate every `tools/call` result's `structuredContent` against
4
+ * the tool's server-supplied `outputSchema`.
5
+ *
6
+ * It wraps the SDK's own default provider (`AjvJsonSchemaValidator`) and
7
+ * changes one thing: the schema is passed through {@link stripSchemaPatterns}
8
+ * first, so no server-supplied `pattern` / `patternProperties` regex is ever
9
+ * run on the native backtracking `RegExp` engine, where a pattern such as
10
+ * `^(a+)+$` and a near-match value would hang the CLI. Every other keyword is
11
+ * still validated by the SDK's validator, unchanged.
12
+ *
13
+ * A schema that declares an identifier is not validated at all (see
14
+ * stripSchemaPatterns); every result passes and the note says so.
15
+ *
16
+ * Skipped regexes are not silent: a failing result's message ends with the
17
+ * "not evaluated" note, and a passing one reports it through `onNote` (the
18
+ * CLI writes it to stderr, keeping stdout's JSON clean).
19
+ */
20
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
21
+ import { schemaNote, stripSchemaPatterns } from "./stripSchemaPatterns.js";
22
+ export class RegexFreeJsonSchemaValidator {
23
+ inner;
24
+ onNote;
25
+ constructor(onNote, inner = new AjvJsonSchemaValidator()) {
26
+ this.onNote = onNote;
27
+ this.inner = inner;
28
+ }
29
+ getValidator(schema) {
30
+ const result = stripSchemaPatterns(schema);
31
+ const note = schemaNote(result);
32
+ if (!result.validate) {
33
+ // Not validated at all (the schema declares an identifier): every
34
+ // result passes, and the note says the schema was not checked.
35
+ return (input) => {
36
+ this.onNote(note);
37
+ // Unchecked by design; `T` is what the caller asked the schema for.
38
+ return { valid: true, data: input, errorMessage: undefined };
39
+ };
40
+ }
41
+ // `stripSchemaPatterns` returns a structural copy of a JsonSchemaType
42
+ // with keywords removed, which is still a JsonSchemaType.
43
+ const validate = this.inner.getValidator(result.schema);
44
+ if (note === undefined)
45
+ return validate;
46
+ return (input) => {
47
+ const checked = validate(input);
48
+ if (checked.valid) {
49
+ this.onNote(note);
50
+ return checked;
51
+ }
52
+ return { ...checked, errorMessage: `${checked.errorMessage} (${note})` };
53
+ };
54
+ }
55
+ }
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Prepare a server-supplied JSON Schema for validation without evaluating any
3
+ * of its regular expressions.
4
+ *
5
+ * `pattern` and `patternProperties` regexes come from the server, and Ajv
6
+ * runs them on the native backtracking `RegExp` engine: a pattern such as
7
+ * `^(a+)+$` checked against a near-match string takes exponential time and
8
+ * freezes the thread doing the validation. Rather than evaluate them, this
9
+ * returns a COPY of the schema with every regex removed, plus counts, so the
10
+ * caller can say visibly what was not checked. The original schema object is
11
+ * never mutated (the UI displays it).
12
+ *
13
+ * INVARIANT: the output schema accepts every value the input schema accepts
14
+ * — it may be less strict, and says so, but never stricter — or validation
15
+ * is skipped altogether (`validate: false`) and the note says so. Every rule
16
+ * below exists to keep that; when one cannot be kept simply, give up rather
17
+ * than add cleverness.
18
+ *
19
+ * 0. A schema that declares an identifier anywhere in a schema position
20
+ * (`$id`, draft-04 `id`, `$anchor`, `$dynamicAnchor`, `$recursiveAnchor`)
21
+ * is not validated at all. Identifiers change how references resolve, and
22
+ * moving a subschema (rule 2) would move its identifier scope with it.
23
+ *
24
+ * 1. Only schema positions are rewritten. The walk follows the keywords that
25
+ * hold subschemas, so a property NAMED `pattern`, or a `pattern` key inside
26
+ * a `const` / `enum` / `default` value, is data and is left alone.
27
+ *
28
+ * 2. Every `$ref` is re-pointed at a sanitized copy, or removed. Ajv resolves
29
+ * a JSON pointer to ANY location in the document (`#/default`, say), and
30
+ * compiles whatever it finds there as a schema — so a reference to a place
31
+ * the walk did not visit would hand Ajv an unsanitized regex. A `$ref` is
32
+ * kept only when it is a local `#/...` pointer to a schema position the
33
+ * walk visited; it is then rewritten to that position's place in the
34
+ * output. Any other `$ref` takes its whole node with it: the node becomes
35
+ * `{}`, siblings included, since Ajv 6 ignores a `$ref`'s siblings and
36
+ * activating them could reject what the reference accepted (under Ajv 8
37
+ * dropping them only loosens). Its subschemas are kept resolvable as in
38
+ * rule 2. A `$dynamicRef` / `$recursiveRef` is removed on its own (Ajv 6
39
+ * does not know it, so its siblings were already active). Each counts as
40
+ * skipped.
41
+ *
42
+ * 3. Removing a constraint loosens the schema that holds it, and loosening is
43
+ * only safe in a positive position. Under `not`, inside a `oneOf` branch
44
+ * (where a looser branch can make two branches match), in an `if`
45
+ * condition (which decides whether `then` or `else` applies), in
46
+ * `contains` (bounded by `maxContains`) and beneath `unevaluated*` (whose
47
+ * verdict depends on what sibling keywords evaluated), a looser subschema
48
+ * can turn a pass into a failure. So a subschema that lost anything —
49
+ * directly, or through a child or a `$ref` target — is "tainted", and a
50
+ * keyword of that kind whose subschema is tainted is removed whole and
51
+ * counted as skipped. Independent sibling constraints are kept. `allOf`,
52
+ * `anyOf`, `properties`, `items`, `then` / `else` and the rest only ever
53
+ * loosen, so they stay.
54
+ *
55
+ * `patternProperties` with at least one key is removed, and so is the
56
+ * `additionalProperties` beside it: without the regexes, which keys are
57
+ * "additional" is unknowable, and `additionalProperties: false` would reject
58
+ * keys the server declared as allowed. An EMPTY `patternProperties` holds no
59
+ * regex, so it and its `additionalProperties` are kept as they are.
60
+ *
61
+ * Nothing that a `$ref` could target is deleted outright: a removed subschema
62
+ * is moved, sanitized, into the root's `definitions` under a generated name
63
+ * (where nothing applies it), and references into it are rewritten there. So
64
+ * the transformed schema always still compiles.
65
+ */
66
+ /** Keywords whose value is a single subschema. */
67
+ const SCHEMA_KEYWORDS = [
68
+ "additionalProperties",
69
+ "additionalItems",
70
+ "contains",
71
+ "propertyNames",
72
+ "if",
73
+ "then",
74
+ "else",
75
+ "not",
76
+ "unevaluatedItems",
77
+ "unevaluatedProperties",
78
+ ];
79
+ /** Keywords whose value is an array of subschemas. */
80
+ const SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"];
81
+ /** Keywords whose value maps names to subschemas. */
82
+ const SCHEMA_MAP_KEYWORDS = [
83
+ "properties",
84
+ "patternProperties",
85
+ "$defs",
86
+ "definitions",
87
+ "dependentSchemas",
88
+ "dependencies",
89
+ ];
90
+ /** Prefix of the `definitions` entries removed subschemas are moved to. */
91
+ const HOLDING_PREFIX = "__inspector_unevaluated_";
92
+ /** Keywords that declare an identifier; any one means "do not validate". */
93
+ const IDENTIFIER_KEYWORDS = [
94
+ "$id",
95
+ "id",
96
+ "$anchor",
97
+ "$dynamicAnchor",
98
+ "$recursiveAnchor",
99
+ ];
100
+ function isObject(value) {
101
+ return typeof value === "object" && value !== null && !Array.isArray(value);
102
+ }
103
+ function isSchema(value) {
104
+ return isObject(value) || typeof value === "boolean";
105
+ }
106
+ /** A map key for a `Rel`. Keywords never contain NUL, so this is unique. */
107
+ const relKey = (rel) => rel.length === 1 ? rel[0] : `${rel[0]}\u0000${rel[1]}`;
108
+ /** Every subschema of `node`, as relative locations. */
109
+ function childRels(node) {
110
+ const rels = [];
111
+ for (const key of SCHEMA_KEYWORDS) {
112
+ if (isSchema(node[key]))
113
+ rels.push([key]);
114
+ }
115
+ for (const key of SCHEMA_ARRAY_KEYWORDS) {
116
+ const value = node[key];
117
+ if (Array.isArray(value)) {
118
+ value.forEach((sub, i) => {
119
+ if (isSchema(sub))
120
+ rels.push([key, String(i)]);
121
+ });
122
+ }
123
+ }
124
+ for (const key of SCHEMA_MAP_KEYWORDS) {
125
+ const value = node[key];
126
+ if (isObject(value)) {
127
+ for (const [name, sub] of Object.entries(value)) {
128
+ // `dependencies` may map to a string array (property dependency).
129
+ if (isSchema(sub))
130
+ rels.push([key, name]);
131
+ }
132
+ }
133
+ }
134
+ // `items` is either one subschema or (draft-07 tuple form) an array.
135
+ if (Array.isArray(node.items)) {
136
+ node.items.forEach((sub, i) => {
137
+ if (isSchema(sub))
138
+ rels.push(["items", String(i)]);
139
+ });
140
+ }
141
+ else if (isSchema(node.items)) {
142
+ rels.push(["items"]);
143
+ }
144
+ return rels;
145
+ }
146
+ function getAt(node, rel) {
147
+ const value = node[rel[0]];
148
+ return rel.length === 1 ? value : value[rel[1]];
149
+ }
150
+ /**
151
+ * Parse a `$ref` the way Ajv resolves a local JSON pointer: split on `/`,
152
+ * then URI-decode and JSON-pointer-unescape each segment. `null` for any
153
+ * reference that is not a local `#` / `#/...` pointer.
154
+ */
155
+ function parseLocalRef(ref) {
156
+ if (ref === "#")
157
+ return [];
158
+ if (!ref.startsWith("#/"))
159
+ return null;
160
+ // An encoded `/` is ambiguous: Ajv 6 splits before decoding (one segment,
161
+ // `x/y`), while Ajv 8 decodes first (two segments, `x` then `y`), so the
162
+ // two can resolve different schemas. Give it up rather than guess. A NUL
163
+ // could collide with the internal child keys, so it is given up too.
164
+ if (/%2f|%00/i.test(ref) || ref.includes("\u0000"))
165
+ return null;
166
+ try {
167
+ return ref
168
+ .slice(2)
169
+ .split("/")
170
+ .map((part) => decodeURIComponent(part).replace(/~1/g, "/").replace(/~0/g, "~"));
171
+ }
172
+ catch {
173
+ return null; // malformed percent-encoding
174
+ }
175
+ }
176
+ const formatSegment = (part) => "/" + encodeURIComponent(part.replace(/~/g, "~0").replace(/\//g, "~1"));
177
+ /** References whose target depends on the dynamic scope; always removed. */
178
+ const DYNAMIC_REF_KEYWORDS = ["$dynamicRef", "$recursiveRef"];
179
+ /**
180
+ * Every pass below is linear in the size of the schema: each position is
181
+ * visited once, each container is copied once, taint spreads along reversed
182
+ * edges with a work list, and positions are numbers rather than paths (a path
183
+ * per position would cost O(depth) each). Only the pointers actually written
184
+ * into rewritten `$ref`s are built as strings, memoized per position.
185
+ */
186
+ export function stripSchemaPatterns(schema) {
187
+ // Pass 1: number every schema position, and record what each depends on.
188
+ const positions = [];
189
+ const rawRefs = [];
190
+ let hasIdentifier = false;
191
+ const analyze = (node, parent) => {
192
+ const id = positions.length;
193
+ const position = {
194
+ node,
195
+ parent,
196
+ children: new Map(),
197
+ direct: false,
198
+ dynamic: false,
199
+ };
200
+ positions.push(position);
201
+ if (!isObject(node))
202
+ return id; // `true` / `false` schemas, or junk
203
+ if (IDENTIFIER_KEYWORDS.some((key) => key in node))
204
+ hasIdentifier = true;
205
+ if (typeof node.pattern === "string")
206
+ position.direct = true;
207
+ if (isObject(node.patternProperties) &&
208
+ Object.keys(node.patternProperties).length > 0) {
209
+ position.direct = true;
210
+ }
211
+ if (typeof node.$ref === "string")
212
+ rawRefs.push([id, node.$ref]);
213
+ if (DYNAMIC_REF_KEYWORDS.some((key) => key in node)) {
214
+ position.dynamic = true;
215
+ }
216
+ for (const rel of childRels(node)) {
217
+ position.children.set(relKey(rel), analyze(getAt(node, rel), id));
218
+ }
219
+ return id;
220
+ };
221
+ analyze(schema, -1);
222
+ /** The position a pointer names, if it names one. */
223
+ const resolve = (segments) => {
224
+ let current = 0;
225
+ for (let i = 0; i < segments.length;) {
226
+ const { children } = positions[current];
227
+ const single = children.get(segments[i]);
228
+ if (single !== undefined) {
229
+ current = single;
230
+ i += 1;
231
+ continue;
232
+ }
233
+ if (i + 1 >= segments.length)
234
+ return null;
235
+ const pair = children.get(`${segments[i]}\u0000${segments[i + 1]}`);
236
+ if (pair === undefined)
237
+ return null;
238
+ current = pair;
239
+ i += 2;
240
+ }
241
+ return current;
242
+ };
243
+ const referrers = new Map();
244
+ for (const [id, ref] of rawRefs) {
245
+ const segments = parseLocalRef(ref);
246
+ const target = segments === null ? null : resolve(segments);
247
+ positions[id].ref = target;
248
+ if (target !== null) {
249
+ const list = referrers.get(target) ?? [];
250
+ list.push(id);
251
+ referrers.set(target, list);
252
+ }
253
+ }
254
+ // Pass 2: a position is tainted if it, a subschema, or a `$ref` target
255
+ // lost anything. Spread from the seeds along reversed edges (to the parent
256
+ // and to every referrer); each position enters the work list at most once.
257
+ const tainted = new Uint8Array(positions.length);
258
+ const work = [];
259
+ const taint = (id) => {
260
+ if (id < 0 || tainted[id])
261
+ return;
262
+ tainted[id] = 1;
263
+ work.push(id);
264
+ };
265
+ positions.forEach((position, id) => {
266
+ if (position.direct || position.dynamic || position.ref === null) {
267
+ taint(id);
268
+ }
269
+ });
270
+ while (work.length > 0) {
271
+ const id = work.pop();
272
+ taint(positions[id].parent);
273
+ for (const referrer of referrers.get(id) ?? [])
274
+ taint(referrer);
275
+ }
276
+ // Pass 3: emit the sanitized copy, recording where each position landed
277
+ // (its output parent and location) so references can be rewritten to it.
278
+ let stripped = 0;
279
+ let skipped = 0;
280
+ const outParent = new Int32Array(positions.length).fill(-2); // -2: unset
281
+ const outRel = new Array(positions.length);
282
+ const refSites = [];
283
+ const holding = [];
284
+ const root = isObject(schema) ? schema : undefined;
285
+ const takenNames = new Set(isObject(root?.definitions) ? Object.keys(root.definitions) : []);
286
+ let nextName = 0;
287
+ const emit = (id) => {
288
+ const { node, children } = positions[id];
289
+ if (!isObject(node))
290
+ return node;
291
+ const childOf = (rel) => children.get(relKey(rel));
292
+ /** Move a subschema somewhere nothing applies it, keeping it resolvable. */
293
+ const relocate = (rel) => holding.push(childOf(rel));
294
+ if (positions[id].ref === null) {
295
+ // A `$ref` that cannot be kept takes its whole node (see rule 2).
296
+ childRels(node).forEach(relocate);
297
+ skipped++;
298
+ return {};
299
+ }
300
+ const copy = { ...node };
301
+ const isTainted = (rel) => tainted[childOf(rel)] === 1;
302
+ if (typeof copy.pattern === "string") {
303
+ delete copy.pattern;
304
+ stripped++;
305
+ }
306
+ if (isObject(copy.patternProperties) &&
307
+ Object.keys(copy.patternProperties).length > 0) {
308
+ for (const name of Object.keys(copy.patternProperties)) {
309
+ if (isSchema(copy.patternProperties[name])) {
310
+ relocate(["patternProperties", name]);
311
+ }
312
+ stripped++;
313
+ }
314
+ delete copy.patternProperties;
315
+ // See the header: without the regexes, which keys are "additional" is
316
+ // unknowable, so this constraint is skipped too.
317
+ if ("additionalProperties" in copy) {
318
+ if (isSchema(copy.additionalProperties)) {
319
+ relocate(["additionalProperties"]);
320
+ }
321
+ delete copy.additionalProperties;
322
+ skipped++;
323
+ }
324
+ }
325
+ // Keywords where a looser subschema can produce a false failure.
326
+ const dropIfTainted = (keyword, alsoDrop = []) => {
327
+ if (!(keyword in copy))
328
+ return;
329
+ const rels = childRels({ [keyword]: copy[keyword] });
330
+ if (!rels.some(isTainted))
331
+ return;
332
+ rels.forEach(relocate);
333
+ delete copy[keyword];
334
+ for (const extra of alsoDrop)
335
+ delete copy[extra];
336
+ skipped++;
337
+ };
338
+ dropIfTainted("not");
339
+ dropIfTainted("oneOf");
340
+ if (isSchema(copy.if) && isTainted(["if"])) {
341
+ for (const keyword of ["if", "then", "else"]) {
342
+ if (isSchema(copy[keyword]))
343
+ relocate([keyword]);
344
+ delete copy[keyword];
345
+ }
346
+ skipped++;
347
+ }
348
+ dropIfTainted("contains", ["minContains", "maxContains"]);
349
+ if (tainted[id]) {
350
+ // What counts as "evaluated" depends on the sibling keywords, which
351
+ // lost something, so these can no longer be judged.
352
+ for (const keyword of ["unevaluatedProperties", "unevaluatedItems"]) {
353
+ if (keyword in copy) {
354
+ if (isSchema(copy[keyword]))
355
+ relocate([keyword]);
356
+ delete copy[keyword];
357
+ skipped++;
358
+ }
359
+ }
360
+ }
361
+ for (const keyword of DYNAMIC_REF_KEYWORDS) {
362
+ if (keyword in copy) {
363
+ delete copy[keyword];
364
+ skipped++;
365
+ }
366
+ }
367
+ const target = positions[id].ref;
368
+ if (typeof copy.$ref === "string" && typeof target === "number") {
369
+ refSites.push({ node: copy, target });
370
+ }
371
+ // Recurse into whatever subschemas are still applied here. Each array or
372
+ // map is copied once, then filled in, so the original is never mutated.
373
+ const cloned = new Set();
374
+ for (const rel of childRels(copy)) {
375
+ const child = childOf(rel);
376
+ outParent[child] = id;
377
+ outRel[child] = rel;
378
+ const sub = emit(child);
379
+ if (rel.length === 1) {
380
+ copy[rel[0]] = sub;
381
+ continue;
382
+ }
383
+ const [keyword, name] = rel;
384
+ if (!cloned.has(keyword)) {
385
+ cloned.add(keyword);
386
+ const container = copy[keyword];
387
+ copy[keyword] = Array.isArray(container)
388
+ ? [...container]
389
+ : { ...container };
390
+ }
391
+ copy[keyword][name] = sub;
392
+ }
393
+ return copy;
394
+ };
395
+ outParent[0] = -1;
396
+ outRel[0] = [];
397
+ const output = emit(0);
398
+ if (isObject(output) && holding.length > 0) {
399
+ const definitions = isObject(output.definitions)
400
+ ? { ...output.definitions }
401
+ : {};
402
+ // A held subschema may itself hold more; iterating the array as it
403
+ // grows picks those up.
404
+ for (const id of holding) {
405
+ let name;
406
+ do {
407
+ name = `${HOLDING_PREFIX}${nextName++}`;
408
+ } while (takenNames.has(name));
409
+ takenNames.add(name);
410
+ outParent[id] = 0;
411
+ outRel[id] = ["definitions", name];
412
+ definitions[name] = emit(id);
413
+ }
414
+ output.definitions = definitions;
415
+ }
416
+ // Build the pointer for each referenced position once, from its parent's.
417
+ const pointers = new Map();
418
+ const pointerOf = (id) => {
419
+ if (outParent[id] === -2)
420
+ return undefined; // never emitted
421
+ if (id === 0)
422
+ return "#";
423
+ const memo = pointers.get(id);
424
+ if (memo !== undefined)
425
+ return memo;
426
+ // Walk up iteratively to the nearest memoized ancestor (no recursion, so
427
+ // a deep schema cannot overflow the stack here).
428
+ const chain = [];
429
+ let current = id;
430
+ while (current !== 0 && !pointers.has(current)) {
431
+ chain.push(current);
432
+ current = outParent[current];
433
+ }
434
+ let pointer = current === 0 ? "#" : pointers.get(current);
435
+ for (let i = chain.length - 1; i >= 0; i--) {
436
+ const link = chain[i];
437
+ pointer += outRel[link].map(formatSegment).join("");
438
+ pointers.set(link, pointer);
439
+ }
440
+ return pointer;
441
+ };
442
+ for (const site of refSites) {
443
+ // Every position is emitted (in place or held), so this always resolves.
444
+ site.node.$ref = pointerOf(site.target);
445
+ }
446
+ return { schema: output, stripped, skipped, validate: !hasIdentifier };
447
+ }
448
+ /** The user-facing note for a result, or `undefined` if nothing was lost. */
449
+ export function schemaNote(result) {
450
+ if (!result.validate) {
451
+ return "this schema was not validated: it declares an $id or $anchor, which the Inspector does not resolve";
452
+ }
453
+ if (result.stripped === 0 && result.skipped === 0)
454
+ return undefined;
455
+ return patternsNotEvaluatedNote(result.stripped, result.skipped);
456
+ }
457
+ /** The user-facing note for a schema that had checks removed. */
458
+ export function patternsNotEvaluatedNote(stripped, skipped = 0) {
459
+ const parts = [];
460
+ if (stripped > 0) {
461
+ parts.push(`${stripped} regex pattern(s) in this schema were not evaluated`);
462
+ }
463
+ if (skipped > 0) {
464
+ parts.push(`${skipped} check(s) in this schema were skipped because they depend on an unevaluated pattern or an unresolvable $ref`);
465
+ }
466
+ return parts.join("; ");
467
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelcontextprotocol/inspector-cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "CLI for the Model Context Protocol inspector",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Model Context Protocol a Series of LF Projects, LLC.",