@littlebigbrain/mcp 0.15.1 → 0.16.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.
@@ -11,12 +11,6 @@ export const QUESTION_MAX_ANCHOR_CHARS = 2_000;
11
11
  export const QUESTION_MAX_TIMELINE = 200;
12
12
  export const QUESTION_MAX_TIMELINE_LABEL_CHARS = 200;
13
13
  export const READ_ONLY = { readOnlyHint: true };
14
- export const IDEMPOTENT_WRITE = {
15
- readOnlyHint: false,
16
- destructiveHint: false,
17
- idempotentHint: true,
18
- openWorldHint: false,
19
- };
20
14
  export const MUTATING = {
21
15
  readOnlyHint: false,
22
16
  destructiveHint: false,
@@ -132,7 +126,9 @@ export const searchFeedbackSchema = z
132
126
  query: z.string().min(1),
133
127
  search_id: z.string().optional(),
134
128
  snapshot: z.record(z.string(), z.unknown()).optional(),
135
- profile: z.string().optional(),
129
+ profile: z
130
+ .enum(["baseline", "scored_atom_v1", "graph_aware_v1", "ndcg_v1"])
131
+ .nullish(),
136
132
  model_id: z.string().optional(),
137
133
  labeler_id: z.string().optional(),
138
134
  labels: z
@@ -259,6 +255,34 @@ export const ontologyEvolveOpSchema = z.discriminatedUnion("op", [
259
255
  .boolean()
260
256
  .optional()
261
257
  .describe("Advisory required flag (not enforced on commit)"),
258
+ unit: z
259
+ .string()
260
+ .optional()
261
+ .describe("Unit of an i64 or f64 field, e.g. mm, cm, kg, m2 or h. Writers that read text, such as values.extract, convert numbers to it"),
262
+ allowed_values: z
263
+ .array(z.string())
264
+ .optional()
265
+ .describe("Enforced allowed values (sh:in): a commit with another value for the field is refused. For keyword, text, i64 and bool fields only"),
266
+ })
267
+ .strict(),
268
+ z
269
+ .object({
270
+ op: z.literal("set_property_unit"),
271
+ property: z.string().describe("An existing i64 or f64 field, by name"),
272
+ unit: z
273
+ .string()
274
+ .optional()
275
+ .describe("The unit's symbol, as add_property takes it; omit it to clear the unit. Stored values are not converted"),
276
+ })
277
+ .strict(),
278
+ z
279
+ .object({
280
+ op: z.literal("set_property_constraint"),
281
+ property: z.string().describe("An existing field, by name"),
282
+ allowed_values: z
283
+ .array(z.string())
284
+ .optional()
285
+ .describe("The allowed values that later commits are checked against; empty or absent clears the constraint. Stored values are not checked again"),
262
286
  })
263
287
  .strict(),
264
288
  z
@@ -331,16 +355,54 @@ export const ontologyEvolveOpSchema = z.discriminatedUnion("op", [
331
355
  })
332
356
  .strict(),
333
357
  ]);
358
+ // The inputs below are as loose as the server: an optional field takes
359
+ // null, and a field the server does not know passes through (the server
360
+ // ignores it). 0.15 callers send `source_id: null` and `key: null`.
361
+ /** A box on a page: 0-based page, coordinates normalized to the page (0 to 1). */
362
+ export const regionSchema = z
363
+ .object({
364
+ page: z.number().int().nonnegative(),
365
+ x0: z.number().min(0).max(1),
366
+ y0: z.number().min(0).max(1),
367
+ x1: z.number().min(0).max(1),
368
+ y1: z.number().min(0).max(1),
369
+ confidence: z.number().min(0).max(1),
370
+ })
371
+ .passthrough();
372
+ /** Why a link holds: a string, or the text with its source and page box. */
373
+ export const evidenceSchema = z.union([
374
+ z.string(),
375
+ z
376
+ .object({
377
+ text: z.string().nullish(),
378
+ source_id: z.string().nullish(),
379
+ region: regionSchema.nullish(),
380
+ observation_id: z.string().nullish(),
381
+ })
382
+ .passthrough(),
383
+ ]);
384
+ /** A vector you computed for one record. */
385
+ export const entityEmbeddingSchema = z
386
+ .object({
387
+ type: z.string(),
388
+ name: z.string(),
389
+ key: z.string().nullish(),
390
+ entity_id: z.string().nullish(),
391
+ model_id: z.string(),
392
+ vector: z.array(z.number()),
393
+ metric: z.enum(["cosine", "dot", "l2"]).nullish(),
394
+ })
395
+ .passthrough();
334
396
  const namedEntityInputSchema = z
335
397
  .object({
336
398
  type: z.string(),
337
399
  name: z.string(),
338
400
  key: z
339
401
  .string()
340
- .optional()
402
+ .nullish()
341
403
  .describe("The record's external key, when it has one"),
342
404
  })
343
- .strict();
405
+ .passthrough();
344
406
  /** Graph facts a review item proposes; accepting commits them. */
345
407
  export const suggestedFactsSchema = z
346
408
  .object({
@@ -351,9 +413,8 @@ export const suggestedFactsSchema = z
351
413
  relation: z.string(),
352
414
  target: namedEntityInputSchema,
353
415
  confidence: z.number().min(0).max(1).optional(),
354
- evidence: z
355
- .unknown()
356
- .optional()
416
+ evidence: evidenceSchema
417
+ .nullish()
357
418
  .describe("Why the link holds: a string, or { text, source_id } as lbb_commit takes it"),
358
419
  })
359
420
  .passthrough())
@@ -398,6 +459,127 @@ export const suggestedFactsSchema = z
398
459
  })
399
460
  .strict()
400
461
  .describe("Graph facts a person confirms: accepting commits them after the change (at most 200 triplets and property rows together). Records graphs only");
462
+ /** The facts as the API takes them. */
463
+ export function suggestedFactsBody(facts) {
464
+ return {
465
+ ...facts,
466
+ // Rows pass through as lbb_commit takes them; the server coerces each
467
+ // value to its field's declared type.
468
+ entity_properties: facts.entity_properties,
469
+ };
470
+ }
471
+ /** A person's answer to a review item, or to one value of a workflow test. */
472
+ export const reviewAnswerSchema = z
473
+ .object({
474
+ decision: z
475
+ .enum(["accept", "reject", "correct", "choose", "cant_tell"])
476
+ .describe("accept, reject, correct (with value), choose (with option) or cant_tell"),
477
+ value: z
478
+ .union([z.number(), z.string(), z.boolean()])
479
+ .optional()
480
+ .describe("correct: the right value, in the property's type. A number may name its unit, e.g. 450 mm"),
481
+ option: z
482
+ .string()
483
+ .optional()
484
+ .describe("choose: the id of the chosen option or grade"),
485
+ note: z
486
+ .string()
487
+ .max(1000)
488
+ .optional()
489
+ .describe("Why, in a few words; for an answer item, the answer text. At most 1,000 characters"),
490
+ })
491
+ .strict();
492
+ /** A workflow to test before it exists: the create request of a values.extract workflow. */
493
+ export const testInstallSchema = z
494
+ .object({
495
+ starter: z.string().describe("The starter workflow, values.extract"),
496
+ name: z
497
+ .string()
498
+ .optional()
499
+ .describe("The name the workflow would get; default the class's local name"),
500
+ watch: z
501
+ .object({
502
+ class: z.string().describe("The class whose instances it reads"),
503
+ fields: z
504
+ .array(z.string())
505
+ .optional()
506
+ .describe("The fields that hold the text, as embedding paths"),
507
+ exclude: z.array(z.string()).optional(),
508
+ })
509
+ .strict()
510
+ .optional(),
511
+ params: jsonObjectSchema
512
+ .optional()
513
+ .describe("The starter workflow's settings, as its catalog entry lists them (the properties to fill, the context)"),
514
+ gate: z
515
+ .object({
516
+ below: z
517
+ .number()
518
+ .min(0)
519
+ .max(1)
520
+ .describe("Values below this support go to review"),
521
+ })
522
+ .strict()
523
+ .optional(),
524
+ })
525
+ .strict();
526
+ /** One label of `lbb_evals action=label`: a result and whether it answers the request. */
527
+ export const evalLabelItemSchema = z
528
+ .object({
529
+ id: z.string(),
530
+ valid: z.boolean(),
531
+ note: z.string().optional(),
532
+ })
533
+ .strict();
534
+ /** A value a correct run writes, or, in leave_out, one it must not write. */
535
+ const evalExpectedValueSchema = z
536
+ .object({
537
+ property: z.string(),
538
+ value: z
539
+ .union([z.number(), z.string(), z.boolean(), z.null()])
540
+ .describe("Numbers compare as numbers, text in lower case. In leave_out, null means no value at all"),
541
+ accept: z
542
+ .array(z.string())
543
+ .optional()
544
+ .describe("Other spellings a text value may take, lower case"),
545
+ stated: z
546
+ .string()
547
+ .optional()
548
+ .describe("The number and unit as the text states them, e.g. 450 mm"),
549
+ quote: z
550
+ .string()
551
+ .optional()
552
+ .describe("Words of the text that state the value"),
553
+ reason: z.string().optional().describe("Why a value is left out"),
554
+ })
555
+ .strict();
556
+ /** One example of a values dataset. */
557
+ export const evalDatasetItemSchema = z
558
+ .object({
559
+ name: z
560
+ .string()
561
+ .describe("The input's name, unique in the dataset; the same name replaces"),
562
+ text: z
563
+ .string()
564
+ .max(40_000)
565
+ .optional()
566
+ .describe("The input text, at most 40,000 characters"),
567
+ values: z.array(evalExpectedValueSchema).optional(),
568
+ leave_out: z.array(evalExpectedValueSchema).optional(),
569
+ iri: z.string().optional().describe("The instance the input came from"),
570
+ case: z
571
+ .string()
572
+ .optional()
573
+ .describe("What the example shows, in one sentence"),
574
+ source: z
575
+ .object({
576
+ kind: z.enum(["test", "review", "answer_key"]),
577
+ id: z.string().optional(),
578
+ })
579
+ .strict()
580
+ .optional(),
581
+ })
582
+ .strict();
401
583
  export const inspectInputSchema = z.discriminatedUnion("action", [
402
584
  z.object({ action: z.literal("guide"), ...readScope }).strict(),
403
585
  z
@@ -444,6 +626,36 @@ export const inspectInputSchema = z.discriminatedUnion("action", [
444
626
  z
445
627
  .object({
446
628
  action: z.literal("entity"),
629
+ version: z.literal("records/1").optional(),
630
+ iri: z
631
+ .string()
632
+ .optional()
633
+ .describe("Authoritative full RDF IRI; selects the lossless records/1 read."),
634
+ key: z
635
+ .string()
636
+ .optional()
637
+ .describe("Stable key with entity_type, resolved from the activated schema at the read snapshot."),
638
+ schema_revision: z
639
+ .string()
640
+ .optional()
641
+ .describe("Expected opaque schema revision for a records/1 read."),
642
+ consistency: z.enum(["strong", "eventual"]).optional(),
643
+ limits: z
644
+ .object({
645
+ properties: z.number().int().positive().max(10_000).optional(),
646
+ outgoing: z.number().int().positive().max(10_000).optional(),
647
+ incoming: z.number().int().positive().max(10_000).optional(),
648
+ assertions: z.number().int().positive().max(100).optional(),
649
+ assertion_statements: z
650
+ .number()
651
+ .int()
652
+ .positive()
653
+ .max(10_000)
654
+ .optional(),
655
+ })
656
+ .strict()
657
+ .optional()
658
+ .describe("Lossless records/1 section bounds. Exceeding a bound refuses the read instead of truncating it."),
447
659
  entity_id: z
448
660
  .string()
449
661
  .optional()
@@ -465,10 +677,9 @@ export const inspectInputSchema = z.discriminatedUnion("action", [
465
677
  })
466
678
  .strict(),
467
679
  ]);
468
- // The graph's RDF projection uses a fixed IRI scheme; teaching it here lets an
469
- // agent write a valid query on the first attempt instead of round-tripping
470
- // through the ontology to reverse-engineer term IRIs.
471
- export const SPARQL_IRI_GUIDE = 'IRI scheme: relations are <https://littlebigbrain.com/r/NAME> (NAME lowercased, e.g. writes_to; reverse a relation with the ^ path operator, no stored inverse triple). Types are <https://littlebigbrain.com/class/NAME> (lowercased), matched as `?x a <…/class/NAME>` with explicit entailment=subclass, rdfs, or owl for inference (default none). Property fields are <https://littlebigbrain.com/p/NAME> (lowercased). The local name is ALWAYS lowercase — an uppercase one (e.g. <…/r/FOR_CLIENT>) is a different, non-existent IRI that silently matches nothing; this tool auto-lowercases the local name of /r/, /class/, and /p/ IRIs for you and adds a `notes` entry when it does, so a stray uppercase still resolves. (Structured mode\'s `predicate` is case-insensitive on its own.) Entities are content-addressed <https://littlebigbrain.com/e/HASH> — never build an entity IRI from a name; anchor a named entity by its label instead: `?e <http://www.w3.org/2000/01/rdf-schema#label> "Acme"`. Discover the exact relation and type names with lbb_inspect action=ontology. SELECT and ASK only (CONSTRUCT/DESCRIBE are rejected).';
680
+ // Native records use a conventional projection; imported RDF keeps its exact IRIs.
681
+ // Explain both so agents do not remint or normalize arbitrary vocabulary.
682
+ export const SPARQL_IRI_GUIDE = 'Imported RDF retains its exact, case-sensitive IRIs. Use returned IRIs unchanged; lbb_inspect action=entity iri=<full IRI> reads any subject with records/1. The following convention applies only to native records: relations are <https://littlebigbrain.com/r/NAME> (NAME lowercased, e.g. writes_to; reverse a relation with the ^ path operator, no stored inverse triple). Types are <https://littlebigbrain.com/class/NAME> (lowercased), matched as `?x a <…/class/NAME>` with explicit entailment=subclass, rdfs, or owl for inference (default none). Property fields are <https://littlebigbrain.com/p/NAME> (lowercased). The local name is ALWAYS lowercase — an uppercase one (e.g. <…/r/FOR_CLIENT>) is a different, non-existent IRI that silently matches nothing; this tool auto-lowercases the local name of /r/, /class/, and /p/ IRIs for you and adds a `notes` entry when it does, so a stray uppercase still resolves. (Structured mode\'s `predicate` is case-insensitive on its own.) Entities are content-addressed <https://littlebigbrain.com/e/HASH> — never build an entity IRI from a name; anchor a named entity by its label instead: `?e <http://www.w3.org/2000/01/rdf-schema#label> "Acme"`. Discover the exact relation and type names with lbb_inspect action=ontology. SELECT and ASK only (CONSTRUCT/DESCRIBE are rejected).';
472
683
  // Search by meaning runs inside SPARQL text as a pattern, so an agent can
473
684
  // filter and join the hits in one query instead of a search then a lookup.
474
685
  export const SPARQL_SEARCH_GUIDE = 'Search by meaning inside the query (the graph needs an embedding; see lbb_embeddings action=list): PREFIX search: <https://littlebigbrain.com/search#> SELECT ?x ?score WHERE { ?x search:similarTo "card payments" ; search:score ?score . ?x <https://littlebigbrain.com/r/calls> ?y } ORDER BY DESC(?score) LIMIT 5. The other patterns filter and join the hits. One search:similarTo per query, with a variable subject, in the main group (not inside OPTIONAL, UNION, MINUS or a subquery). The object is text, an entity IRI (records like that one) or a vector literal. search:top N sets the hits (1 to 1000; default the query LIMIT, then 10) and search:embedding "name" searches one embedding. FILTER(?score > 0.8) keeps close hits. The result\'s search field reports the plan (nearest, filter_first or search_first), the hits and complete. For a question in words, add search:rerank true ; search:relevance ?r and ORDER BY DESC(?r): the rerank model (Jev) reads the best hits with the text, keeps the top that answer it and binds its answer (0 to 1); search.rerank reports the status. Only a text query reranks.';
@@ -212,6 +212,18 @@ export function compactLimits(detail) {
212
212
  return { maxItems: 5, maxString: 300 };
213
213
  }
214
214
  }
215
+ /**
216
+ * Items one list page asks for: `limit`, at most `most` (the route's own
217
+ * cap) and at most the items the detail level shows (5, 20 or 100), so the
218
+ * detail level never cuts the page. A page over the output budget
219
+ * (HARD_OUTPUT_CHARS) is cut too: `runPage` then asks the server again for
220
+ * the items that fit, so next_cursor continues after the last item the
221
+ * caller saw.
222
+ */
223
+ export function pageLimit(detail, limit, most) {
224
+ const shown = compactLimits(normalizeDetail(detail)).maxItems;
225
+ return Math.min(limit ?? shown, shown, most);
226
+ }
215
227
  export function truncateValue(value, limits, state) {
216
228
  if (typeof value === "string") {
217
229
  if (value.length <= limits.maxString)
@@ -267,10 +279,75 @@ export function defaultSummary(label, value, truncated) {
267
279
  : "";
268
280
  return `${label}${countText}${truncated ? " [truncated]" : ""}`;
269
281
  }
282
+ /** The next_cursor of a list page, kept whole: a cut cursor is no cursor. */
283
+ function keepCursor(value, data) {
284
+ const cursor = value?.next_cursor;
285
+ return typeof cursor === "string" && data && typeof data === "object"
286
+ ? { ...data, next_cursor: cursor }
287
+ : data;
288
+ }
289
+ /** The items of a list page (`{data, has_more, next_cursor}`), else undefined. */
290
+ function pageItems(value) {
291
+ if (!value || typeof value !== "object")
292
+ return undefined;
293
+ const page = value;
294
+ return Array.isArray(page.data) &&
295
+ ("next_cursor" in page || "has_more" in page)
296
+ ? page.data
297
+ : undefined;
298
+ }
299
+ /** Whether the envelope of `value` fits the output budget without the hard cap. */
300
+ function fitsOutput(label, value, detailArg) {
301
+ const detail = normalizeDetail(detailArg);
302
+ const state = { truncated: false };
303
+ const data = keepCursor(value, truncateValue(value, compactLimits(detail), state));
304
+ const text = JSON.stringify({
305
+ summary: defaultSummary(label, value, state.truncated),
306
+ data,
307
+ counts: countsFor(value),
308
+ truncated: state.truncated || undefined,
309
+ next: state.truncated ? { detail: nextDetail(detail) } : undefined,
310
+ }, null, 2);
311
+ // A margin for the fields the envelope adds beside the page.
312
+ return text.length <= HARD_OUTPUT_CHARS - 512;
313
+ }
314
+ /**
315
+ * Run a call that answers one list page. `fetch(limit)` asks the server
316
+ * for at most `limit` items after the call's cursor. When the page does
317
+ * not fit the output budget, the envelope would cut it to a few items
318
+ * while next_cursor points after the last item asked for; so the server
319
+ * is asked again for the items that fit, and next_cursor continues after
320
+ * the last item shown.
321
+ */
322
+ export async function runPage(client, label, detail, limit, fetch) {
323
+ try {
324
+ let page = await fetch(limit);
325
+ for (let attempt = 0; attempt < 3; attempt += 1) {
326
+ const items = pageItems(page);
327
+ if (!items || items.length <= 1 || fitsOutput(label, page, detail))
328
+ break;
329
+ let lo = 1;
330
+ let hi = items.length - 1;
331
+ while (lo < hi) {
332
+ const mid = Math.ceil((lo + hi) / 2);
333
+ const fits = fitsOutput(label, { ...page, data: items.slice(0, mid) }, detail);
334
+ if (fits)
335
+ lo = mid;
336
+ else
337
+ hi = mid - 1;
338
+ }
339
+ page = await fetch(lo);
340
+ }
341
+ return toolResult(envelope(label, page, detail));
342
+ }
343
+ catch (error) {
344
+ return errorResult(await enrichError(client, error));
345
+ }
346
+ }
270
347
  export function envelope(label, value, detailArg, next) {
271
348
  const detail = normalizeDetail(detailArg);
272
349
  const state = { truncated: false };
273
- let data = truncateValue(value, compactLimits(detail), state);
350
+ let data = keepCursor(value, truncateValue(value, compactLimits(detail), state));
274
351
  let result = {
275
352
  summary: defaultSummary(label, value, state.truncated),
276
353
  data,
@@ -284,7 +361,7 @@ export function envelope(label, value, detailArg, next) {
284
361
  if (text.length <= HARD_OUTPUT_CHARS)
285
362
  return result;
286
363
  const hardState = { truncated: true };
287
- data = truncateValue(value, { maxItems: 3, maxString: 120 }, hardState);
364
+ data = keepCursor(value, truncateValue(value, { maxItems: 3, maxString: 120 }, hardState));
288
365
  result = {
289
366
  summary: `${label} [hard-capped for MCP output]`,
290
367
  data,
@@ -297,10 +374,10 @@ export function envelope(label, value, detailArg, next) {
297
374
  return result;
298
375
  return {
299
376
  summary: `${label} [hard-capped for MCP output]`,
300
- data: {
377
+ data: keepCursor(value, {
301
378
  note: "The response was too large for the MCP tool result. Narrow the query or request a smaller top_k.",
302
379
  preview: text.slice(0, 20_000),
303
- },
380
+ }),
304
381
  counts: countsFor(value),
305
382
  truncated: true,
306
383
  next: { ...(next ?? {}), detail: "full" },
@@ -404,6 +481,14 @@ export function toolResult(value) {
404
481
  structuredContent: value,
405
482
  };
406
483
  }
484
+ /** An error the tool raises itself, with its own code (`approval_declined`). */
485
+ export class ToolError extends Error {
486
+ code;
487
+ constructor(code, message) {
488
+ super(message);
489
+ this.code = code;
490
+ }
491
+ }
407
492
  export function errorResult(error) {
408
493
  const payload = error instanceof LbbError
409
494
  ? {
@@ -417,7 +502,7 @@ export function errorResult(error) {
417
502
  }
418
503
  : {
419
504
  type: "tool_error",
420
- code: "tool_error",
505
+ code: error instanceof ToolError ? error.code : "tool_error",
421
506
  message: error instanceof Error ? error.message : String(error),
422
507
  param: null,
423
508
  request_id: null,
@@ -709,6 +794,7 @@ export async function analyze(scopedClient, p) {
709
794
  }
710
795
  else if (metric === "sparql") {
711
796
  const body = requireObject(p.sparql, "sparql");
797
+ // The body is the caller's JSON; the server checks it.
712
798
  const res = (await scopedClient.sparql(body));
713
799
  const groups = res.groups ?? [];
714
800
  const first = groups[0];