@littlebigbrain/mcp 0.5.1 → 0.6.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/README.md +119 -126
- package/dist/http-server.d.ts +0 -1
- package/dist/http-server.js +0 -1
- package/dist/index.d.ts +1 -0
- package/dist/metadata-pages.d.ts +0 -2
- package/dist/metadata-pages.js +2 -12
- package/dist/query-observer.d.ts +33 -0
- package/dist/query-observer.js +95 -0
- package/dist/rdf-tool.js +2 -3
- package/dist/server.d.ts +2 -1
- package/dist/server.js +2 -2
- package/dist/stdio.js +1 -2
- package/dist/tool-contracts.js +67 -6
- package/dist/tool-runtime.js +54 -56
- package/dist/tools.d.ts +2 -1
- package/dist/tools.js +339 -131
- package/package.json +3 -3
package/dist/tool-contracts.js
CHANGED
|
@@ -14,6 +14,12 @@ export const MUTATING = {
|
|
|
14
14
|
destructiveHint: false,
|
|
15
15
|
openWorldHint: false,
|
|
16
16
|
};
|
|
17
|
+
export const DESTRUCTIVE = {
|
|
18
|
+
readOnlyHint: false,
|
|
19
|
+
destructiveHint: true,
|
|
20
|
+
idempotentHint: true,
|
|
21
|
+
openWorldHint: false,
|
|
22
|
+
};
|
|
17
23
|
export const detailSchema = z
|
|
18
24
|
.enum(["compact", "standard", "full"])
|
|
19
25
|
.optional()
|
|
@@ -34,10 +40,6 @@ export const graphScope = {
|
|
|
34
40
|
.string()
|
|
35
41
|
.optional()
|
|
36
42
|
.describe("Graph to target; defaults to the connection's graph"),
|
|
37
|
-
branch: z
|
|
38
|
-
.string()
|
|
39
|
-
.optional()
|
|
40
|
-
.describe("Branch to target; defaults to the connection's branch"),
|
|
41
43
|
};
|
|
42
44
|
export const jsonObjectSchema = z.record(z.string(), z.unknown());
|
|
43
45
|
export const jsonObjectArraySchema = z.array(jsonObjectSchema);
|
|
@@ -481,11 +483,71 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
|
|
|
481
483
|
.nonnegative()
|
|
482
484
|
.optional()
|
|
483
485
|
.describe("Snapshot pin: run the query as of this commit_seq. Errors if past head."),
|
|
486
|
+
request: z
|
|
487
|
+
.string()
|
|
488
|
+
.optional()
|
|
489
|
+
.describe("The user's own words behind this query (managed evals). When present the server records an eval trace and the result carries its trace_id; label the rows valid or not with lbb_evals action=label. Omit on continuation pages."),
|
|
484
490
|
row_limit: rowLimitSchema,
|
|
485
491
|
cursor: cursorSchema,
|
|
486
492
|
...readScope,
|
|
487
493
|
})
|
|
488
494
|
.strict(),
|
|
495
|
+
z
|
|
496
|
+
.object({
|
|
497
|
+
mode: z.literal("search"),
|
|
498
|
+
embedding: z
|
|
499
|
+
.string()
|
|
500
|
+
.optional()
|
|
501
|
+
.describe("One embedding by name (see lbb_embeddings action=list). Omit it to search every searchable class of the graph; each hit names its class."),
|
|
502
|
+
text: z
|
|
503
|
+
.string()
|
|
504
|
+
.optional()
|
|
505
|
+
.describe("The query text (required); embedded with the graph's model."),
|
|
506
|
+
top_k: z.number().int().positive().max(200).optional(),
|
|
507
|
+
probe: z
|
|
508
|
+
.number()
|
|
509
|
+
.int()
|
|
510
|
+
.positive()
|
|
511
|
+
.optional()
|
|
512
|
+
.describe("Clusters to read (default 4·√clusters, at least 8). More reads cost latency and find more."),
|
|
513
|
+
include: z
|
|
514
|
+
.array(z.enum(["text"]))
|
|
515
|
+
.optional()
|
|
516
|
+
.describe("text: return the embedded text of each hit."),
|
|
517
|
+
filter: z
|
|
518
|
+
.array(z
|
|
519
|
+
.object({
|
|
520
|
+
class: z
|
|
521
|
+
.union([z.string(), z.array(z.string())])
|
|
522
|
+
.optional()
|
|
523
|
+
.describe("A class condition: one class IRI or a list (any of); subclasses too. One class condition per search."),
|
|
524
|
+
via: z
|
|
525
|
+
.string()
|
|
526
|
+
.optional()
|
|
527
|
+
.describe("A relationship condition: the relationship, a local name (calls), prefix:name, or <iri>."),
|
|
528
|
+
to: z
|
|
529
|
+
.union([z.string(), z.array(z.string())])
|
|
530
|
+
.optional()
|
|
531
|
+
.describe("With via: the entity, an IRI or a name (its label; a spelling slip resolves when one entity is clearly closest), or a list (any of)."),
|
|
532
|
+
direction: z
|
|
533
|
+
.enum(["out", "in"])
|
|
534
|
+
.optional()
|
|
535
|
+
.describe("With via: out (default), the hit links to the entity; in, the entity links to the hit."),
|
|
536
|
+
})
|
|
537
|
+
.strict())
|
|
538
|
+
.optional()
|
|
539
|
+
.describe('Conditions every hit must meet: {"class": …} or {"via": …, "to": …}. The response\'s filter shows how each condition resolved; an unknown relationship or name answers with the options.'),
|
|
540
|
+
explain: z
|
|
541
|
+
.boolean()
|
|
542
|
+
.optional()
|
|
543
|
+
.describe("Plan without running: the scope, the resolved filter, and the allowed count. No model call."),
|
|
544
|
+
request: z
|
|
545
|
+
.string()
|
|
546
|
+
.optional()
|
|
547
|
+
.describe("The user's own words (managed evals): records an eval trace; label it with lbb_evals action=label."),
|
|
548
|
+
...readScope,
|
|
549
|
+
})
|
|
550
|
+
.strict(),
|
|
489
551
|
z
|
|
490
552
|
.object({
|
|
491
553
|
mode: z.literal("analyze"),
|
|
@@ -510,7 +572,6 @@ export const configureInputSchema = z.discriminatedUnion("action", [
|
|
|
510
572
|
.optional()
|
|
511
573
|
.describe("Preview the exact definition without creating a graph or writing metadata."),
|
|
512
574
|
graph: z.string().describe("Graph to create or redefine"),
|
|
513
|
-
branch: graphScope.branch,
|
|
514
575
|
entity_types: z.array(z.union([z.string(), jsonObjectSchema])).optional(),
|
|
515
576
|
relations: z.array(z.union([z.string(), jsonObjectSchema])).optional(),
|
|
516
577
|
source: z.string().optional(),
|
|
@@ -561,7 +622,7 @@ export const configureInputSchema = z.discriminatedUnion("action", [
|
|
|
561
622
|
* rejects as `Expected object, received string`.
|
|
562
623
|
*
|
|
563
624
|
* Flatten the union into a single ZodObject purely for advertisement and
|
|
564
|
-
* transport: the discriminant becomes an enum, every
|
|
625
|
+
* transport: the discriminant becomes an enum, every variant field is merged in
|
|
565
626
|
* as optional, and unknown keys pass through. Each handler still `safeParse`s the
|
|
566
627
|
* raw args against the original strict union before dispatching, so per-variant
|
|
567
628
|
* required/forbidden fields are enforced exactly as before.
|
package/dist/tool-runtime.js
CHANGED
|
@@ -2,9 +2,7 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { LbbError } from "@littlebigbrain/client";
|
|
4
4
|
import { DEFAULT_DETAIL, HARD_OUTPUT_CHARS, MAX_QUERY_ROW_LIMIT, } from "./tool-contracts.js";
|
|
5
|
-
export const scoped = (client, graph
|
|
6
|
-
? client.withScope({ graph, branch })
|
|
7
|
-
: client;
|
|
5
|
+
export const scoped = (client, graph) => graph !== undefined ? client.withScope({ graph }) : client;
|
|
8
6
|
export function normalizeDetail(detail) {
|
|
9
7
|
return detail === "standard" || detail === "full" ? detail : DEFAULT_DETAIL;
|
|
10
8
|
}
|
|
@@ -50,9 +48,6 @@ export function assertCursorScope(args, cursor) {
|
|
|
50
48
|
if (args.graph !== undefined && args.graph !== cursor.graph) {
|
|
51
49
|
throw new Error("cursor graph does not match the supplied graph argument");
|
|
52
50
|
}
|
|
53
|
-
if (args.branch !== undefined && args.branch !== cursor.branch) {
|
|
54
|
-
throw new Error("cursor branch does not match the supplied branch argument");
|
|
55
|
-
}
|
|
56
51
|
}
|
|
57
52
|
export function rowPageFrom(value) {
|
|
58
53
|
if (!value || typeof value !== "object")
|
|
@@ -190,7 +185,7 @@ export function normalizeLbbIris(query) {
|
|
|
190
185
|
export function contentHashKey(scope, payload) {
|
|
191
186
|
const digest = createHash("sha256")
|
|
192
187
|
.update(stableJson({
|
|
193
|
-
scope: { graph: scope.graph ?? null
|
|
188
|
+
scope: { graph: scope.graph ?? null },
|
|
194
189
|
payload,
|
|
195
190
|
}))
|
|
196
191
|
.digest("hex");
|
|
@@ -311,7 +306,7 @@ export function envelope(label, value, detailArg, next) {
|
|
|
311
306
|
next: { ...(next ?? {}), detail: "full" },
|
|
312
307
|
};
|
|
313
308
|
}
|
|
314
|
-
export function queryEnvelope(label, value, detailArg, rowPage, next, repage) {
|
|
309
|
+
export function queryEnvelope(label, value, detailArg, rowPage, next, repage, options = {}) {
|
|
315
310
|
// A query page is data, not a preview: keep entire terms/values and page
|
|
316
311
|
// the rows themselves to fit the wire budget. The cursor must advance by
|
|
317
312
|
// what the caller actually received, even inside a partial server page.
|
|
@@ -326,7 +321,11 @@ export function queryEnvelope(label, value, detailArg, rowPage, next, repage) {
|
|
|
326
321
|
: undefined;
|
|
327
322
|
const rows = key === "bindings" ? results?.bindings : key ? source[key] : undefined;
|
|
328
323
|
if (!rowPage || !repage || !Array.isArray(rows)) {
|
|
329
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
...envelope(label, value, detailArg, next),
|
|
326
|
+
row_page: rowPage,
|
|
327
|
+
...(options.notes?.length ? { notes: options.notes } : {}),
|
|
328
|
+
};
|
|
330
329
|
}
|
|
331
330
|
if (rows.length !== rowPage.returned) {
|
|
332
331
|
throw new Error("Query row count does not match its page; refusing a cursor that could skip rows.");
|
|
@@ -354,11 +353,13 @@ export function queryEnvelope(label, value, detailArg, rowPage, next, repage) {
|
|
|
354
353
|
next: hasMore
|
|
355
354
|
? continuationNext(repage, page.offset + count, repage.row_limit)
|
|
356
355
|
: undefined,
|
|
356
|
+
...(options.notes?.length ? { notes: options.notes } : {}),
|
|
357
357
|
};
|
|
358
358
|
};
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
359
|
+
// Retain the existing reserve, but include actual normalization notes in
|
|
360
|
+
// every fit so even a large note list cannot push the final text over cap.
|
|
361
|
+
// Count UTF-8 bytes, including multibyte literals, not JavaScript code units.
|
|
362
|
+
const fits = (page) => Buffer.byteLength(serializeQueryEnvelope(page, options.textFormat), "utf8") <=
|
|
362
363
|
HARD_OUTPUT_CHARS - 2048;
|
|
363
364
|
const full = build(rows.length);
|
|
364
365
|
if (fits(full))
|
|
@@ -373,10 +374,28 @@ export function queryEnvelope(label, value, detailArg, rowPage, next, repage) {
|
|
|
373
374
|
hi = mid - 1;
|
|
374
375
|
}
|
|
375
376
|
if (lo === 0) {
|
|
376
|
-
throw new Error(
|
|
377
|
+
throw new Error(rows.length === 0
|
|
378
|
+
? "Query page metadata or normalization notes exceed the MCP output budget. Narrow the query or use the SPARQL HTTP API; no rows were skipped."
|
|
379
|
+
: "One query row exceeds the MCP output budget. Project fewer fields or use the SPARQL HTTP API for this value; no rows were skipped.");
|
|
377
380
|
}
|
|
378
381
|
return build(lo);
|
|
379
382
|
}
|
|
383
|
+
/** One formatter for query page fitting and the text returned to MCP clients. */
|
|
384
|
+
export function serializeQueryEnvelope(value, format = "compact") {
|
|
385
|
+
return format === "pretty"
|
|
386
|
+
? JSON.stringify(value, null, 2)
|
|
387
|
+
: JSON.stringify(value);
|
|
388
|
+
}
|
|
389
|
+
export function queryToolResult(value, format) {
|
|
390
|
+
const text = serializeQueryEnvelope(value, format);
|
|
391
|
+
if (Buffer.byteLength(text, "utf8") > HARD_OUTPUT_CHARS) {
|
|
392
|
+
throw new Error("Query response exceeds the MCP output budget. Narrow the query or use the SPARQL HTTP API; no rows were skipped.");
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
content: [{ type: "text", text }],
|
|
396
|
+
structuredContent: value,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
380
399
|
export function toolResult(value) {
|
|
381
400
|
return {
|
|
382
401
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -416,60 +435,38 @@ export function errorResult(error) {
|
|
|
416
435
|
};
|
|
417
436
|
}
|
|
418
437
|
/**
|
|
419
|
-
* A graph-scope 404 surfaces as a raw object-storage key
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
438
|
+
* A graph-scope 404 surfaces as a raw object-storage key ending in
|
|
439
|
+
* `heads/current.json`, which only means something if you already know the
|
|
440
|
+
* graph's real name. Rewrite it into an actionable message: name the graph this
|
|
441
|
+
* request targeted and — via the tenant-scoped `GET /v1/graphs`, which resolves
|
|
442
|
+
* even when the scoped graph is absent — list the graphs that do exist and tell
|
|
443
|
+
* the caller to pass `graph=`. Every other error passes through untouched,
|
|
444
|
+
* including a head 404 on a graph the listing still reports.
|
|
426
445
|
*/
|
|
427
446
|
export async function enrichError(client, error) {
|
|
428
447
|
if (!(error instanceof LbbError) || error.status !== 404)
|
|
429
448
|
return error;
|
|
430
|
-
const match = /graphs\/([
|
|
449
|
+
const match = /graphs\/([^/\s]+)\/(?:[^/\s]+\/)*heads\/current\.json/.exec(error.message);
|
|
431
450
|
if (!match)
|
|
432
451
|
return error;
|
|
433
|
-
const [, graph
|
|
434
|
-
let
|
|
452
|
+
const [, graph] = match;
|
|
453
|
+
let names = [];
|
|
435
454
|
try {
|
|
436
455
|
const listed = (await client.listGraphs());
|
|
437
|
-
|
|
438
|
-
? [
|
|
439
|
-
{
|
|
440
|
-
graph_id: g.graph_id,
|
|
441
|
-
branches: Array.isArray(g.branches)
|
|
442
|
-
? g.branches
|
|
443
|
-
: undefined,
|
|
444
|
-
},
|
|
445
|
-
]
|
|
446
|
-
: []);
|
|
456
|
+
names = (listed.data ?? []).flatMap((g) => typeof g.graph_id === "string" ? [g.graph_id] : []);
|
|
447
457
|
}
|
|
448
458
|
catch {
|
|
449
459
|
// Listing failed too (auth, transport); fall back to the generic-but-actionable hint.
|
|
450
460
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
"Pass an existing branch as the `branch` argument.";
|
|
461
|
-
}
|
|
462
|
-
else {
|
|
463
|
-
const names = graphs.map((g) => g.graph_id);
|
|
464
|
-
const list = names.length > 0
|
|
465
|
-
? ` Available graphs in this tenant: ${names.slice(0, 50).join(", ")}.`
|
|
466
|
-
: "";
|
|
467
|
-
const example = names.length > 0 ? ` (e.g. graph="${names[0]}")` : "";
|
|
468
|
-
message =
|
|
469
|
-
`graph "${graph}" was not found in this tenant — this request targeted graph "${graph}", branch "${branch}" ` +
|
|
470
|
-
`(either you passed it or it is the connection default).${list} ` +
|
|
471
|
-
`Pass an existing graph as the \`graph\` argument${example}.`;
|
|
472
|
-
}
|
|
461
|
+
if (names.includes(graph))
|
|
462
|
+
return error;
|
|
463
|
+
const list = names.length > 0
|
|
464
|
+
? ` Available graphs in this tenant: ${names.slice(0, 50).join(", ")}.`
|
|
465
|
+
: "";
|
|
466
|
+
const example = names.length > 0 ? ` (e.g. graph="${names[0]}")` : "";
|
|
467
|
+
const message = `graph "${graph}" was not found in this tenant — this request targeted graph "${graph}" ` +
|
|
468
|
+
`(either you passed it or it is the connection default).${list} ` +
|
|
469
|
+
`Pass an existing graph as the \`graph\` argument${example}.`;
|
|
473
470
|
return new LbbError(error.status, error.body, {
|
|
474
471
|
type: error.type,
|
|
475
472
|
code: error.code,
|
|
@@ -660,7 +657,8 @@ export async function guide(scopedClient) {
|
|
|
660
657
|
capability: {
|
|
661
658
|
search_feedback: "When a result set is useful or clearly wrong, write relevance labels with lbb_commit mode=search_feedback. Use grade 3 for ideal/good results, grade 1 for partially relevant results, and grade 0 for bad results. Include the original query, search_id when present, target identity, rank, score, and an optional split train/eval/unspecified. These labels are stored in __lbb_feedback/main and later exported as qrels-style training/eval data; they are not customer facts.",
|
|
662
659
|
inspect: "Use lbb_inspect for ontology, schema metadata, the published conformance report, metadata, state/history/why, and this guide. Use lbb_query with SPARQL property paths for exact path queries.",
|
|
663
|
-
ontology_decorations: "lbb_inspect action=ontology returns a decoration_status catalog: each ontology decoration is enforced (the engine acts on it — state_reducer, value_type, super_types, properties, supernode_policy; cardinality, which GET /v1/ontology/conformance audits as sh:maxCount; and inverse_name/symmetric, which SPARQL resolves as relation aliases — an inverse name is queryable directly (lowered to ^forward, no stored inverse triple) and a symmetric relation matches both directions), advisory (transitive, temporal_semantics, required), or reserved (stored but unwired — default_weight, resolvable, alias/embedding_fields). You can also always reverse any relation in SPARQL by flipping the triple pattern or using ^forward. Each relation_def also carries edge_count — the number of current edges of that relation in this
|
|
660
|
+
ontology_decorations: "lbb_inspect action=ontology returns a decoration_status catalog: each ontology decoration is enforced (the engine acts on it — state_reducer, value_type, super_types, properties, supernode_policy; cardinality, which GET /v1/ontology/conformance audits as sh:maxCount; and inverse_name/symmetric, which SPARQL resolves as relation aliases — an inverse name is queryable directly (lowered to ^forward, no stored inverse triple) and a symmetric relation matches both directions), advisory (transitive, temporal_semantics, required), or reserved (stored but unwired — default_weight, resolvable, alias/embedding_fields). You can also always reverse any relation in SPARQL by flipping the triple pattern or using ^forward. Each relation_def also carries edge_count — the number of current edges of that relation in this graph's snapshot — so you can tell at a glance which declared relations are actually populated (edge_count 0 = declared but unused) without a separate summary call.",
|
|
661
|
+
search: "Use lbb_query mode=search to find instances by meaning. It covers every searchable class; filter narrows it by class and by relationship, and the graph checks every hit. To plan a search: (1) lbb_embeddings action=list gives the searchable classes, the facts each text is made from, and the graph's model. (2) lbb_query mode=sparql shows the relationships of a class on a sample: PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> SELECT ?p (COUNT(DISTINCT ?s) AS ?n) (SAMPLE(?l) AS ?example) WHERE { { SELECT ?s WHERE { ?s a <CLASS> } LIMIT 500 } ?s ?p ?o . FILTER(isIRI(?o) && ?p != rdf:type) OPTIONAL { ?o rdfs:label ?l } } GROUP BY ?p ORDER BY DESC(?n); write ?o ?p ?s for the links into the class, and SELECT ?c WHERE { ?c rdfs:subClassOf* <CLASS> } (same prefixes) for its subclasses. (3) lbb_query mode=search with filter [{class}, {via, to}] and explain=true returns the plan: the resolved relationship and entities, the allowed count, and the mode, with no model call. Check it, then run the search without explain. A name in to resolves against labels (a small typo too); an unknown relationship or name answers with the options.",
|
|
664
662
|
query: "Use lbb_query for structured SPARQL-subset bodies, SPARQL text, and canned analysis. SPARQL is the only query surface. A mode=structured body is { patterns: [{ subject, predicate, object }], filters?, group_by?, group_keys?, aggregates?, having? }; a pattern `predicate` is a relation name and is case-insensitive. mode=structured GROUP BY is not limited to entity identity: group_keys can key on a typed scalar property or calendar bucket. In SPARQL text, relations are <https://littlebigbrain.com/r/NAME> and types <https://littlebigbrain.com/class/NAME>. Each query pins one published watermark.",
|
|
665
663
|
write: "Use lbb_commit for fact writes and relevance feedback; omitted idempotency keys are content-derived so retries dedupe. Set typed scalar attributes via entity_properties once the field is registered (add it on a live graph with lbb_configure evolve_ontology add_property). For feedback, use mode=search_feedback rather than fact triplets.",
|
|
666
664
|
configure: "Use lbb_configure dry_run=true to preview definitions, ordered evolution (including add_super_types), or SHACL publication. Definition extracts native metadata; use lbb_rdf import to store full RDF/OWL and lbb_rdf update for additive INSERT DATA; RDF deletion/replacement is unsupported. Follow every lbb_inspect ontology/schema next cursor; fields are complete and metadata changes invalidate the cursor. Publication is asynchronous: inspect action=publication, then verify asserted axioms AND expected answers with lbb_query entailment=owl. SHACL conformance alone does not prove ontology completeness. First identify the questions the model must answer, reusable vocabularies, dates, evidence, and distinctions such as hypothesis versus confirmed fact.",
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { LbbClient } from "@littlebigbrain/client";
|
|
3
|
-
|
|
3
|
+
import { type LbbServerOptions } from "./query-observer.js";
|
|
4
|
+
export declare function registerLbbTools(server: McpServer, client: LbbClient, options?: LbbServerOptions): void;
|