@littlebigbrain/mcp 0.4.3 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @littlebigbrain/mcp
2
2
 
3
- Ten task-shaped [MCP](https://modelcontextprotocol.io) tools that let Claude, Cursor, Codex, or any MCP client search, query, and write a [Little Big Brain](https://littlebigbrain.com) graph. Ships two ways: a hosted endpoint with OAuth sign-in, and a local stdio server.
3
+ Eight task-shaped [MCP](https://modelcontextprotocol.io) tools that let Claude, Cursor, Codex, or any MCP client search, query, and write a [Little Big Brain](https://littlebigbrain.com) graph. Ships two ways: a hosted endpoint with OAuth sign-in, and a local stdio server.
4
4
 
5
5
  ## Hosted (OAuth) — recommended
6
6
 
@@ -58,16 +58,95 @@ missing.
58
58
 
59
59
  | Tool | Use it for |
60
60
  | --- | --- |
61
- | `lbb_inspect` | ontology, schema, entity, state, history, and provenance |
61
+ | `lbb_inspect` | graph discovery, complete paginated ontology/schema, publication status, entity, state, history, and provenance |
62
+ | `lbb_rdf` | import full RDF/OWL or add axioms using INSERT DATA |
62
63
  | `lbb_query` | SPARQL text, structured SPARQL bodies, and canned analysis |
63
64
  | `lbb_commit` | facts, properties, and embeddings |
64
65
  | `lbb_observe` | conversation episodes plus reviewed extraction |
65
66
  | `lbb_branch` | isolation branches and validated merge |
66
67
  | `lbb_models` | shadow evaluation and training datasets |
67
- | `lbb_configure` | ontology definition and atomic schema publication |
68
+ | `lbb_configure` | native ontology definition/evolution and SHACL preview/publication |
68
69
 
69
70
  Read tools return compact structured envelopes by default — use `detail`, `row_limit`, and returned cursors to page without silently truncating. Write tools derive an idempotency key unless you provide one.
70
71
 
72
+ Both `lbb_query` SPARQL modes (`sparql` and `structured`) support retained commit reads
73
+ through `as_of_commit_seq`. When omitted, the connector pins the current head
74
+ commit and reuses it for cursor pages. Valid-time `as_of` is unsupported and is
75
+ rejected before an API call, including when carried in an old cursor. Start a
76
+ new query without that selector or choose a retained commit sequence.
77
+
78
+ ## Create and evolve an ontology through MCP
79
+
80
+ Start with `lbb_inspect action=guide`; `action=graphs` helps select an existing
81
+ scope, and a missing graph returns bootstrap guidance. Decide what questions
82
+ the graph must answer before choosing classes and relations. Distinguish
83
+ source-backed facts from hypotheses, and preserve evidence and dates.
84
+
85
+ Native metadata and stored RDF axioms are separate:
86
+
87
+ - `lbb_configure action=define_ontology` accepts a friendly `spec`, including
88
+ class `super_types`. Unknown spec fields fail explicitly. `lbb_json` expects
89
+ an internal serialized ontology, not a friendly spec. Raw OWL supplied to
90
+ configure is reduced to native metadata; it is not stored as a full document.
91
+ - `lbb_rdf action=import` stores the complete Turtle, N-Triples, N-Quads, or TriG
92
+ document as queryable graph facts, including RDF lists, annotations, and OWL
93
+ axioms. Pass `source`; the published RDF tier supports only the default RDF
94
+ graph. Dataset formats must contain only default-graph quads. The first RDF
95
+ data write selects RDF-native storage, which refuses
96
+ later property-graph commits; choose the write workflow before bootstrap.
97
+ - `lbb_configure action=evolve_ontology` supports explicit native changes,
98
+ including `add_super_types`. `dry_run: true` previews define/evolve/publish;
99
+ the same flag previews `lbb_commit mode=facts` without writing.
100
+ - `lbb_rdf action=update` submits SPARQL Update unchanged; currently only
101
+ `INSERT DATA` is supported. DELETE, WHERE, and graph replacement are refused.
102
+ Re-importing is
103
+ additive and does not remove obsolete axioms. Content-based retry keys are
104
+ automatic; use a new explicit key for an intentional repeat after other edits.
105
+
106
+ For example, add a superclass without a browser or RDF conversion:
107
+
108
+ ```json
109
+ {
110
+ "action": "update",
111
+ "update": "INSERT DATA { <urn:Person> <http://www.w3.org/2000/01/rdf-schema#subClassOf> <urn:Contact> }"
112
+ }
113
+ ```
114
+
115
+ Removing or replacing RDF axioms requires native bounded update support in the
116
+ engine. Until then, import a revised document into a new versioned LBB graph,
117
+ verify it, and explicitly switch consumers. Do not implicitly delete the original.
118
+
119
+ `publish_schema` activates SHACL shapes against unchanged native metadata.
120
+ Its preview checks parsing and compatibility without writing objects or
121
+ scheduling jobs; it does not audit the entire graph. Preview restrictive
122
+ native edits with evolve, resolve conflicts, then apply. For restrictive
123
+ SHACL, use warn → inspect conformance → repair → reject.
124
+
125
+ After applying, inspect `action=publication`, then verify both asserted axioms
126
+ and expected inferred answers. `lbb_query mode=sparql` accepts explicit
127
+ `entailment: "none" | "subclass" | "rdfs" | "owl"` (default `none`),
128
+ `consistency: "eventual" | "strong"`, and `min_indexed_seq`. Cursors retain
129
+ these controls. OWL is the server's supported inference profile, not arbitrary
130
+ OWL DL. An upload acknowledgement is not proof of successful reasoning.
131
+
132
+ To read asserted axioms in the default graph, query with `entailment: "none"`
133
+ and follow all returned row cursors:
134
+
135
+ ```sparql
136
+ SELECT ?s ?p ?o WHERE {
137
+ ?s ?p ?o
138
+ } ORDER BY ?s ?p ?o
139
+ ```
140
+
141
+ `lbb_inspect action=ontology` and `action=schema` page complete native metadata
142
+ with `page_size` (default 50, maximum 500), optional `section`, and `cursor`.
143
+ Pass the returned `next` arguments until absent. These pages preserve nested
144
+ values even with `detail=compact`; they never replace the remainder with a
145
+ suggestion to repeat `detail=full`. If a single entry is too large, the result
146
+ contains `entry_fragment`: concatenate `serialized_json` by `char_offset`, then
147
+ JSON-parse the completed entry. Changed metadata invalidates the cursor rather
148
+ than mixing versions. Restart inspection after applying edits.
149
+
71
150
  ## Embed the server
72
151
 
73
152
  For self-hosting behind your own auth, the package also serves the tools over HTTP:
@@ -85,3 +164,17 @@ createMcpHttpServer({
85
164
  The embedded server passes a key bearer to the data plane; the hosted endpoint's OAuth and ownership layer is served separately by the Little Big Brain API.
86
165
 
87
166
  Full tool schemas and examples: [docs.littlebigbrain.com/sdks/mcp](https://docs.littlebigbrain.com/sdks/mcp/).
167
+
168
+ ## Local end-to-end ontology check
169
+
170
+ From the repository root, build the server and SDKs, then opt into the isolated
171
+ real-server MCP test (it creates and removes its own temporary data root):
172
+
173
+ ```sh
174
+ cargo build -p lbb-server
175
+ npm run build -w @littlebigbrain/client
176
+ LBB_TEST_SERVER_BIN="$PWD/target/debug/lbb-server" npm test -w @littlebigbrain/mcp
177
+ ```
178
+
179
+ The test verifies native hierarchy evolution, preservation of RDF annotations,
180
+ subclass/inverse inference, additive edits, and refusal of unsupported deletion.
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { type LbbClient } from "@littlebigbrain/client";
3
+ import { metadataPageSchema } from "./tool-contracts.js";
4
+ type Request = z.infer<z.ZodObject<typeof metadataPageSchema>> & {
5
+ action: "ontology" | "schema";
6
+ graph?: string;
7
+ branch?: string;
8
+ };
9
+ /** Stateless pagination of complete entries, including their full nested values.
10
+ * A digest rejects mixed-version reads instead of skipping/repeating definitions
11
+ * when schema or population counts change between pages. */
12
+ export declare function metadataPage(client: LbbClient, args: Request): Promise<{
13
+ summary: string;
14
+ data: Record<string, unknown>;
15
+ counts: Record<string, number> | undefined;
16
+ row_page: {
17
+ returned: number;
18
+ total: number;
19
+ offset: number;
20
+ limit: number;
21
+ has_more: boolean;
22
+ next_offset: number | undefined;
23
+ };
24
+ next: {
25
+ action: "ontology" | "schema";
26
+ graph: string | undefined;
27
+ branch: string | undefined;
28
+ section: string | undefined;
29
+ page_size: number;
30
+ cursor: string;
31
+ } | undefined;
32
+ }>;
33
+ export {};
@@ -0,0 +1,150 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { HARD_OUTPUT_CHARS } from "./tool-contracts.js";
4
+ import { countsFor, stableJson } from "./tool-runtime.js";
5
+ const cursorSchema = z
6
+ .object({
7
+ v: z.literal(1),
8
+ action: z.enum(["ontology", "schema"]),
9
+ graph: z.string().optional(),
10
+ branch: z.string().optional(),
11
+ section: z.string().optional(),
12
+ page_size: z.number().int().min(1).max(500),
13
+ offset: z.number().int().nonnegative().safe(),
14
+ fragment_offset: z.number().int().nonnegative().safe().optional(),
15
+ fingerprint: z.string(),
16
+ })
17
+ .strict();
18
+ /** Stateless pagination of complete entries, including their full nested values.
19
+ * A digest rejects mixed-version reads instead of skipping/repeating definitions
20
+ * when schema or population counts change between pages. */
21
+ export async function metadataPage(client, args) {
22
+ let cursor;
23
+ if (args.cursor) {
24
+ try {
25
+ cursor = cursorSchema.parse(JSON.parse(Buffer.from(args.cursor, "base64url").toString("utf8")));
26
+ }
27
+ catch {
28
+ throw new Error("invalid lbb_inspect cursor; restart without cursor");
29
+ }
30
+ for (const key of [
31
+ "action",
32
+ "graph",
33
+ "branch",
34
+ "section",
35
+ "page_size",
36
+ ]) {
37
+ if (args[key] !== undefined && args[key] !== cursor[key]) {
38
+ throw new Error(`cursor ${key} does not match the supplied ${key}`);
39
+ }
40
+ }
41
+ }
42
+ const graph = cursor?.graph ?? args.graph;
43
+ const branch = cursor?.branch ?? args.branch;
44
+ const section = cursor?.section ?? args.section;
45
+ const pageSize = cursor?.page_size ?? args.page_size ?? 50;
46
+ const target = client.withScope({ graph, branch });
47
+ const value = (args.action === "ontology"
48
+ ? await target.ontologyView({ counts: true })
49
+ : await target.schema.view());
50
+ if (section !== undefined && !Array.isArray(value[section])) {
51
+ throw new Error(`unknown metadata section '${section}'; choose ${Object.keys(value)
52
+ .filter((k) => Array.isArray(value[k]))
53
+ .join(", ")}`);
54
+ }
55
+ const fingerprint = createHash("sha256")
56
+ .update(stableJson(value))
57
+ .digest("hex");
58
+ if (cursor && cursor.fingerprint !== fingerprint) {
59
+ throw new Error("ontology/schema metadata changed during pagination; restart without cursor to avoid mixing versions");
60
+ }
61
+ const base = {
62
+ v: 1,
63
+ action: args.action,
64
+ graph,
65
+ branch,
66
+ section,
67
+ page_size: pageSize,
68
+ fingerprint,
69
+ };
70
+ const arrays = Object.entries(value).filter(([key, child]) => Array.isArray(child) && (section === undefined || key === section));
71
+ const entries = arrays.flatMap(([key, items]) => items.map((item) => ({ key, item })));
72
+ const data = Object.fromEntries(Object.entries(value).filter(([, child]) => !Array.isArray(child)));
73
+ for (const [key] of arrays)
74
+ data[key] = [];
75
+ const offset = cursor?.offset ?? 0;
76
+ if (offset > entries.length)
77
+ throw new Error("invalid lbb_inspect cursor offset");
78
+ const makeEnvelope = (returned, fragmentOffset) => {
79
+ const end = offset + returned;
80
+ const hasMore = end < entries.length;
81
+ return {
82
+ summary: `lbb_inspect.${args.action}: ${returned} complete metadata entries (${end}/${entries.length})`,
83
+ data,
84
+ counts: countsFor(value),
85
+ row_page: {
86
+ returned,
87
+ total: entries.length,
88
+ offset,
89
+ limit: pageSize,
90
+ has_more: hasMore,
91
+ next_offset: hasMore ? end : undefined,
92
+ },
93
+ next: hasMore
94
+ ? {
95
+ action: args.action,
96
+ graph,
97
+ branch,
98
+ section,
99
+ page_size: pageSize,
100
+ cursor: Buffer.from(JSON.stringify({
101
+ ...base,
102
+ offset: end,
103
+ fragment_offset: fragmentOffset,
104
+ })).toString("base64url"),
105
+ }
106
+ : undefined,
107
+ };
108
+ };
109
+ let returned = 0;
110
+ for (const { key, item } of entries.slice(offset, offset + pageSize)) {
111
+ data[key].push(item);
112
+ if (JSON.stringify(makeEnvelope(returned + 1), null, 2).length >
113
+ HARD_OUTPUT_CHARS) {
114
+ data[key].pop();
115
+ if (returned === 0) {
116
+ // Even one unusually large definition remains readable through MCP.
117
+ // Reassemble serialized_json in order, then JSON.parse the full entry.
118
+ const serialized = JSON.stringify(item);
119
+ const start = cursor?.fragment_offset ?? 0;
120
+ if (start >= serialized.length)
121
+ throw new Error("invalid lbb_inspect fragment offset");
122
+ let size = Math.min(16_000, serialized.length - start);
123
+ for (;;) {
124
+ const end = start + size;
125
+ const complete = end === serialized.length;
126
+ const result = {
127
+ ...makeEnvelope(complete ? 1 : 0, complete ? undefined : end),
128
+ summary: `lbb_inspect.${args.action}: fragment of ${key} entry ${offset}; concatenate serialized_json fragments then JSON.parse`,
129
+ entry_fragment: {
130
+ section: key,
131
+ entry_offset: offset,
132
+ char_offset: start,
133
+ total_chars: serialized.length,
134
+ serialized_json: serialized.slice(start, end),
135
+ complete,
136
+ },
137
+ };
138
+ if (JSON.stringify(result, null, 2).length <= HARD_OUTPUT_CHARS)
139
+ return result;
140
+ if (size === 1)
141
+ throw new Error("metadata envelope exceeds MCP output budget");
142
+ size = Math.max(1, Math.floor(size / 2));
143
+ }
144
+ }
145
+ break;
146
+ }
147
+ returned++;
148
+ }
149
+ return makeEnvelope(returned);
150
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { LbbClient } from "@littlebigbrain/client";
3
+ export declare function registerRdfTool(server: McpServer, client: LbbClient): void;
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ import { graphScope, detailSchema, advertiseUnion } from "./tool-contracts.js";
3
+ import { contentHashKey, errorResult, run, scoped } from "./tool-runtime.js";
4
+ const input = z.discriminatedUnion("action", [
5
+ z
6
+ .object({
7
+ action: z.literal("import"),
8
+ source: z
9
+ .string()
10
+ .min(1)
11
+ .describe("Complete RDF document. Preserves OWL axioms, RDF lists, labels, comments, and external IRIs as graph facts."),
12
+ format: z.enum(["turtle", "ntriples", "nquads", "trig"]).optional(),
13
+ base_iri: z.string().optional(),
14
+ blank_node_scope: z
15
+ .string()
16
+ .optional()
17
+ .describe("Stable document scope for blank labels across import chunks."),
18
+ idempotency_key: z.string().optional(),
19
+ ...graphScope,
20
+ detail: detailSchema,
21
+ })
22
+ .strict(),
23
+ z
24
+ .object({
25
+ action: z.literal("update"),
26
+ update: z
27
+ .string()
28
+ .min(1)
29
+ .describe("SPARQL INSERT DATA text to add axioms. Submitted unchanged. DELETE/WHERE/CLEAR and named graphs are currently unsupported and fail without mutation."),
30
+ idempotency_key: z.string().optional(),
31
+ ...graphScope,
32
+ detail: detailSchema,
33
+ })
34
+ .strict(),
35
+ ]);
36
+ export function registerRdfTool(server, client) {
37
+ server.registerTool("lbb_rdf", {
38
+ description: "Store and extend complete RDF/OWL documents through MCP. import accepts Turtle/N-Triples/N-Quads/TriG without conversion; update executes INSERT DATA for additive edits. Replacing/removing axioms is unsupported; use a new versioned LBB graph for a revised document. Named RDF graphs are unsupported; Turtle/N-Triples use the default graph, and dataset formats must contain only default-graph quads. These write graph facts, distinct from lbb_configure's native schema metadata. A first RDF write selects RDF-native storage, which refuses later property-graph commits; choose the write workflow before bootstrap. Retries deduplicate by content unless idempotency_key is supplied. A completed write schedules publication; inspect action=publication and verify using lbb_query entailment=owl.",
39
+ inputSchema: advertiseUnion("action", input),
40
+ annotations: {
41
+ readOnlyHint: false,
42
+ destructiveHint: true,
43
+ idempotentHint: true,
44
+ openWorldHint: false,
45
+ },
46
+ }, (raw) => {
47
+ const parsed = input.safeParse(raw);
48
+ if (!parsed.success)
49
+ return errorResult(parsed.error);
50
+ const args = parsed.data;
51
+ return run(client, `lbb_rdf.${args.action}`, args.detail, async () => {
52
+ const target = scoped(client, args.graph, args.branch);
53
+ const { idempotency_key } = args;
54
+ const operation = { ...args };
55
+ delete operation.detail;
56
+ delete operation.idempotency_key;
57
+ const key = idempotency_key ??
58
+ contentHashKey({ graph: args.graph, branch: args.branch }, operation);
59
+ if (args.action === "import") {
60
+ return target.importRdf(args.source, {
61
+ format: args.format ?? "turtle",
62
+ baseIri: args.base_iri,
63
+ blankNodeScope: args.blank_node_scope,
64
+ strict: true,
65
+ edgeIdempotency: "skip_unchanged",
66
+ idempotencyKey: key,
67
+ });
68
+ }
69
+ await target.request("POST", "/update", {
70
+ rawBody: args.update,
71
+ contentType: "application/sparql-update",
72
+ idempotencyKey: key,
73
+ });
74
+ return {
75
+ accepted: true,
76
+ idempotency_key: key,
77
+ publication: "pending; inspect action=publication before verifying inferred results",
78
+ };
79
+ });
80
+ });
81
+ }
package/dist/server.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { registerLbbTools } from "./tools.js";
4
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
3
5
  /** Build an MCP server exposing the little big brain tool belt, bound to one client. */
4
6
  export function buildLbbServer(client) {
5
- const server = new McpServer({ name: "lbb", version: "0.1.0" });
7
+ const server = new McpServer({ name: "lbb", version });
6
8
  registerLbbTools(server, client);
7
9
  return server;
8
10
  }
@@ -42,6 +42,36 @@ export const graphScope = {
42
42
  export const jsonObjectSchema = z.record(z.string(), z.unknown());
43
43
  export const jsonObjectArraySchema = z.array(jsonObjectSchema);
44
44
  export const readScope = { detail: detailSchema, ...graphScope };
45
+ export const metadataPageSchema = {
46
+ page_size: z
47
+ .number()
48
+ .int()
49
+ .min(1)
50
+ .max(500)
51
+ .optional()
52
+ .describe("Maximum complete metadata entries per page; defaults to 50. Nested fields are never truncated; an oversized single entry returns serialized_json fragments to concatenate and parse."),
53
+ cursor: z
54
+ .string()
55
+ .optional()
56
+ .describe("Opaque lbb_inspect continuation. Repeat action and pass the returned next arguments; rejects changed metadata."),
57
+ section: z
58
+ .string()
59
+ .optional()
60
+ .describe("Optional top-level array to inspect, e.g. entity_type_defs, relation_defs, property_defs, classes, or relations. Omit to page through all sections."),
61
+ };
62
+ const queryConsistencySchema = {
63
+ consistency: z
64
+ .enum(["eventual", "strong"])
65
+ .optional()
66
+ .describe("Read consistency. strong requires publication through head; a pending response is retryable."),
67
+ min_indexed_seq: z
68
+ .number()
69
+ .int()
70
+ .nonnegative()
71
+ .safe()
72
+ .optional()
73
+ .describe("Read-after-write publication floor. Preserved across cursor pages."),
74
+ };
45
75
  export const entitySelectorSchema = z
46
76
  .object({
47
77
  entity_type: z
@@ -138,6 +168,13 @@ export const shapeSourceSchema = z
138
168
  .strict();
139
169
  export const schemaModeSchema = z.enum(["off", "warn", "reject"]);
140
170
  export const ontologyEvolveOpSchema = z.discriminatedUnion("op", [
171
+ z
172
+ .object({
173
+ op: z.literal("add_super_types"),
174
+ entity_type: z.string(),
175
+ super_types: z.array(z.string()).min(1),
176
+ })
177
+ .strict(),
141
178
  z
142
179
  .object({
143
180
  op: z.literal("widen_relation"),
@@ -284,11 +321,25 @@ export const ontologyEvolveOpSchema = z.discriminatedUnion("op", [
284
321
  ]);
285
322
  export const inspectInputSchema = z.discriminatedUnion("action", [
286
323
  z.object({ action: z.literal("guide"), ...readScope }).strict(),
287
- z.object({ action: z.literal("ontology"), ...readScope }).strict(),
324
+ z
325
+ .object({
326
+ action: z.literal("ontology"),
327
+ ...metadataPageSchema,
328
+ ...readScope,
329
+ })
330
+ .strict(),
288
331
  z
289
332
  .object({ action: z.literal("ontology_conformance"), ...readScope })
290
333
  .strict(),
291
- z.object({ action: z.literal("schema"), ...readScope }).strict(),
334
+ z
335
+ .object({
336
+ action: z.literal("schema"),
337
+ ...metadataPageSchema,
338
+ ...readScope,
339
+ })
340
+ .strict(),
341
+ z.object({ action: z.literal("graphs"), ...readScope }).strict(),
342
+ z.object({ action: z.literal("publication"), ...readScope }).strict(),
292
343
  z
293
344
  .object({
294
345
  action: z.literal("ontology_search"),
@@ -375,11 +426,12 @@ export const inspectInputSchema = z.discriminatedUnion("action", [
375
426
  // The graph's RDF projection uses a fixed IRI scheme; teaching it here lets an
376
427
  // agent write a valid query on the first attempt instead of round-tripping
377
428
  // through the ontology to reverse-engineer term IRIs.
378
- 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 rdfs:subClassOf closure on by default. 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).';
429
+ 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).';
379
430
  export const queryInputSchema = z.discriminatedUnion("mode", [
380
431
  z
381
432
  .object({
382
433
  mode: z.literal("structured"),
434
+ ...queryConsistencySchema,
383
435
  body: jsonObjectSchema
384
436
  .optional()
385
437
  .describe('Structured SPARQL-subset request body. Shape: { patterns: [{ subject, predicate, object }], filters?, group_by?, group_keys?, aggregates?, having?, order_by?, select?, limit?, distinct? }. Each pattern term is { var: "x" } or a fixed { entity: { entity_type, name } }; `predicate` is a relation name and is case-insensitive here (FOR_CLIENT and for_client both resolve — unlike SPARQL text, which needs the lowercased IRI local name). ' +
@@ -390,11 +442,11 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
390
442
  '{ "value": <typed> } — and <typed> is exactly one wrapper: { "str": "…" }, { "i64": 5 }, { "f64": 0.9 }, { "bool": true }, { "date_time": "2026-01-01" } (RFC3339), or { "entity": { "entity_type": "T", "name": "N" } }. ' +
391
443
  'Complete runnable example — deals whose amount ≥ 1000000: { "patterns": [{ "subject": { "var": "d" }, "predicate": "for_client", "object": { "var": "c" } }], "filters": [{ "compare": { "op": "ge", "left": { "property": { "var": "d", "field": "amount" } }, "right": { "value": { "f64": 1000000 } } } }] }. ' +
392
444
  "Comparisons use the field's real declared type (numbers as numbers, datetimes as instants), so they run server-side. " +
393
- 'GROUP BY supports both entity-identity keys (group_by: ["s"]) and typed scalar keys via group_keys: a property value ({ property: { var, field, as } }) or a calendar bucket of a datetime property ({ date_bucket: { var, field, granularity: year|month|week|day|hour, as } }). Scalar keys come back per group under value_keys[as] — so a per-area breakdown or a commits-per-month time series is one server-side query, no client-side bucketing. Worked example -- commits per area per month in one query: { "patterns": [{ "subject": { "var": "c" }, "predicate": "committed_to", "object": { "var": "repo" } }], "group_keys": [{ "date_bucket": { "var": "c", "field": "committed_at", "granularity": "month", "as": "m" } }, { "property": { "var": "c", "field": "area", "as": "area" } }], "aggregates": [{ "func": "count", "as": "n" }], "order_by": [{ "var": "m" }] } -- area and committed_at are typed entity attributes (set via entity_properties; readable flat under attributes, never a nested metadata blob), and each group returns value_keys.m + value_keys.area + aggregates.n. `having: [...]` takes the same filter shape over the aggregated groups (e.g. { "compare": { "op": "gt", "left": { "var": "n" }, "right": { "value": { "i64": 10 } } } }). A `combinators` key (UNION/OPTIONAL/MINUS/EXISTS) is rejected here; express those with SPARQL text under mode=sparql. Cheap aggregate count: pair an equality having (e.g. { "compare": { "op": "eq", "left": { "var": "n" }, "right": { "value": { "i64": 4 } } } }) with row_limit: 1 -- the response row_page.total reports how many groups match without materializing them all, so you read the count off row_page.total instead of paging every matching row. For snapshot pinning prefer the top-level `as_of` / `as_of_commit_seq` arguments below; a bare `as_of` key inside the body is rejected (the body\'s valid-time field is `as_of_valid_time`).'),
445
+ 'GROUP BY supports both entity-identity keys (group_by: ["s"]) and typed scalar keys via group_keys: a property value ({ property: { var, field, as } }) or a calendar bucket of a datetime property ({ date_bucket: { var, field, granularity: year|month|week|day|hour, as } }). Scalar keys come back per group under value_keys[as] — so a per-area breakdown or a commits-per-month time series is one server-side query, no client-side bucketing. Worked example -- commits per area per month in one query: { "patterns": [{ "subject": { "var": "c" }, "predicate": "committed_to", "object": { "var": "repo" } }], "group_keys": [{ "date_bucket": { "var": "c", "field": "committed_at", "granularity": "month", "as": "m" } }, { "property": { "var": "c", "field": "area", "as": "area" } }], "aggregates": [{ "func": "count", "as": "n" }], "order_by": [{ "var": "m" }] } -- area and committed_at are typed entity attributes (set via entity_properties; readable flat under attributes, never a nested metadata blob), and each group returns value_keys.m + value_keys.area + aggregates.n. `having: [...]` takes the same filter shape over the aggregated groups (e.g. { "compare": { "op": "gt", "left": { "var": "n" }, "right": { "value": { "i64": 10 } } } }). A `combinators` key (UNION/OPTIONAL/MINUS/EXISTS) is rejected here; express those with SPARQL text under mode=sparql. Cheap aggregate count: pair an equality having (e.g. { "compare": { "op": "eq", "left": { "var": "n" }, "right": { "value": { "i64": 4 } } } }) with row_limit: 1 -- the response row_page.total reports how many groups match without materializing them all, so you read the count off row_page.total instead of paging every matching row. For snapshot pinning use the top-level `as_of_commit_seq` argument or the same body field. Valid-time `as_of` and `as_of_valid_time` selectors are unsupported and rejected before HTTP.'),
394
446
  as_of: z
395
447
  .string()
396
448
  .optional()
397
- .describe("Snapshot pin (valid-time, RFC3339): evaluate the body as of this instant. Folded into the request's `as_of_valid_time`. Top-level here is the supported spelling — a bare `as_of` inside the body is rejected, since the server silently ignores it."),
449
+ .describe("Unsupported in structured and SPARQL text modes; use as_of_commit_seq for a retained commit snapshot."),
398
450
  as_of_commit_seq: z
399
451
  .number()
400
452
  .int()
@@ -409,11 +461,20 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
409
461
  z
410
462
  .object({
411
463
  mode: z.literal("sparql"),
464
+ ...queryConsistencySchema,
465
+ entailment: z
466
+ .enum(["none", "subclass", "rdfs", "owl"])
467
+ .optional()
468
+ .describe("Reasoning over the pinned RDF generation. Defaults to none. owl includes RDFS, inverse relationships and the supported OWL profile."),
412
469
  query: z
413
470
  .string()
414
471
  .optional()
415
- .describe(`SPARQL 1.1 query text (SELECT or ASK). ${SPARQL_IRI_GUIDE} Example: SELECT ?service ?db WHERE { ?service <https://littlebigbrain.com/r/writes_to> ?db } LIMIT 10`),
416
- as_of: z.string().optional(),
472
+ .describe(`SPARQL 1.1 query text (SELECT or ASK). Valid-time as_of is unsupported; use as_of_commit_seq for a retained commit snapshot. ${SPARQL_IRI_GUIDE} Example: SELECT ?service ?db WHERE { ?service <https://littlebigbrain.com/r/writes_to> ?db } LIMIT 10`),
473
+ // Kept for an actionable error when an older connector sends this field.
474
+ as_of: z
475
+ .string()
476
+ .optional()
477
+ .describe("Unsupported in SPARQL text mode; use as_of_commit_seq."),
417
478
  as_of_commit_seq: z
418
479
  .number()
419
480
  .int()
@@ -444,10 +505,14 @@ export const configureInputSchema = z.discriminatedUnion("action", [
444
505
  z
445
506
  .object({
446
507
  action: z.literal("define_ontology"),
508
+ dry_run: z
509
+ .boolean()
510
+ .optional()
511
+ .describe("Preview the exact definition without creating a graph or writing metadata."),
447
512
  graph: z.string().describe("Graph to create or redefine"),
448
513
  branch: graphScope.branch,
449
- entity_types: jsonObjectArraySchema.optional(),
450
- relations: jsonObjectArraySchema.optional(),
514
+ entity_types: z.array(z.union([z.string(), jsonObjectSchema])).optional(),
515
+ relations: z.array(z.union([z.string(), jsonObjectSchema])).optional(),
451
516
  source: z.string().optional(),
452
517
  format: ontologyFormatSchema.optional(),
453
518
  merge_default: z.boolean().optional(),
@@ -456,6 +521,10 @@ export const configureInputSchema = z.discriminatedUnion("action", [
456
521
  z
457
522
  .object({
458
523
  action: z.literal("publish_schema"),
524
+ dry_run: z
525
+ .boolean()
526
+ .optional()
527
+ .describe("Parse and check schema compatibility without activation or validation jobs. Does not audit all data."),
459
528
  ontology: ontologySourceSchema.optional(),
460
529
  shapes: shapeSourceSchema.optional(),
461
530
  desired_mode: schemaModeSchema.optional(),
@@ -466,6 +535,10 @@ export const configureInputSchema = z.discriminatedUnion("action", [
466
535
  z
467
536
  .object({
468
537
  action: z.literal("evolve_ontology"),
538
+ dry_run: z
539
+ .boolean()
540
+ .optional()
541
+ .describe("Preview ordered changes and current-data conflicts without writing metadata."),
469
542
  ops: z
470
543
  .array(ontologyEvolveOpSchema)
471
544
  .min(1)
@@ -473,7 +546,7 @@ export const configureInputSchema = z.discriminatedUnion("action", [
473
546
  allow_data_conflicts: z
474
547
  .boolean()
475
548
  .optional()
476
- .describe("Apply subtractive ops (narrow/remove) even when current data conflicts; affected records are kept and begin to warn. Default false rejects a conflicting subtractive request and reports the conflicts."),
549
+ .describe("Deprecated compatibility flag; does not bypass conflicts. Preview subtractive changes with dry_run=true, repair the reported conflicts, then apply."),
477
550
  ...graphScope,
478
551
  })
479
552
  .strict(),
@@ -666,7 +666,20 @@ export function buildPossibilities(relations) {
666
666
  return possibilities;
667
667
  }
668
668
  export async function guide(scopedClient) {
669
- const s = (await scopedClient.summary());
669
+ let summary;
670
+ try {
671
+ summary = await scopedClient.summary();
672
+ }
673
+ catch (error) {
674
+ if (!(error instanceof LbbError) || error.code !== "graph_not_found")
675
+ throw error;
676
+ return {
677
+ graph_exists: false,
678
+ graphs: await scopedClient.listGraphs(),
679
+ how_to: "Choose an existing graph explicitly, or bootstrap the requested graph. For native named entities use lbb_configure action=define_ontology with dry_run=true, inspect warnings, then publish and lbb_commit. For RDF/OWL use lbb_rdf action=import, then SPARQL INSERT DATA for further writes; an RDF-native graph refuses property-graph commits. Native schema definition alone does not store an OWL document as graph facts.",
680
+ };
681
+ }
682
+ const s = summary;
670
683
  const entityTypes = [...(s.entity_types ?? [])].sort((a, b) => b.count - a.count);
671
684
  const relations = [...(s.relations ?? [])].sort((a, b) => b.count - a.count);
672
685
  return {
@@ -684,7 +697,7 @@ export async function guide(scopedClient) {
684
697
  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 branch'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.",
685
698
  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.",
686
699
  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.",
687
- configure: "Use lbb_configure to define a new ontology, evolve an existing one in place, or atomically publish ontology/SHACL bundle metadata. Conformance validation runs durably after publication.",
700
+ 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.",
688
701
  },
689
702
  possibilities: buildPossibilities(relations),
690
703
  how_to: "Ground with lbb_inspect action=guide, query with lbb_query (structured bodies or SPARQL text), rate useful/partial/bad result sets when you have a judgment, inspect exact entities/schema with lbb_inspect, then write graph facts with lbb_commit or configuration with lbb_configure only when intended.",
@@ -780,6 +793,7 @@ export function ontologyDefineBody(p) {
780
793
  source: p.source,
781
794
  format: p.format ?? "auto",
782
795
  merge_default: p.merge_default ?? false,
796
+ ...(p.dry_run !== undefined ? { dry_run: p.dry_run } : {}),
783
797
  };
784
798
  }
785
799
  if (!p.entity_types?.length || !p.relations?.length) {
@@ -792,6 +806,7 @@ export function ontologyDefineBody(p) {
792
806
  }),
793
807
  format: "spec",
794
808
  merge_default: p.merge_default ?? false,
809
+ ...(p.dry_run !== undefined ? { dry_run: p.dry_run } : {}),
795
810
  };
796
811
  }
797
812
  /**
package/dist/tools.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { z } from "zod";
2
+ import { metadataPage } from "./metadata-pages.js";
3
+ import { registerRdfTool } from "./rdf-tool.js";
2
4
  import { IDEMPOTENT_WRITE, MUTATING, READ_ONLY, configureInputSchema, configureWireSchema, detailSchema, graphScope, inspectInputSchema, inspectWireSchema, jsonObjectSchema, queryInputSchema, queryWireSchema, searchFeedbackSchema, } from "./tool-contracts.js";
3
5
  import { analyze, assertCursorScope, contentHashKey, decodeQueryCursor, effectiveRowLimit, enrichError, errorResult, guide, normalizeDetail, normalizeLbbIris, ontologyDefineBody, queryCommitPin, queryEnvelope, requireString, rowPageFrom, rowPageNext, run, scoped, stableJson, toolResult, } from "./tool-runtime.js";
4
6
  export function registerLbbTools(server, client) {
7
+ registerRdfTool(server, client);
5
8
  server.registerTool("lbb_inspect", {
6
- description: "Read graph context and exact graph facts. Actions: guide, ontology, ontology_conformance, schema, ontology_search, metadata, entity, state, history, transitions, why. schema reads active ontology/SHACL bundle metadata without running validation. ontology_conformance serves the durable report referenced by the pinned published root. entity returns one node's metadata, scalar attributes, bounded Base-backed edge neighborhood, history, and observations. Use lbb_query with SPARQL property paths for precise path selection.",
9
+ description: "Read graph context and exact graph facts. Actions: guide, graphs, publication, ontology, ontology_conformance, schema, ontology_search, metadata, entity, state, history, transitions, why. graphs works before bootstrap; publication reports whether writes are queryable. ontology and schema return complete entries with page_size, section and cursor; follow next until absent. schema reads active native ontology/SHACL metadata without running validation. Query asserted RDF/OWL axioms separately with lbb_query. ontology_conformance serves the durable report referenced by the pinned published root. entity returns one node's metadata, scalar attributes, bounded Base-backed edge neighborhood, history, and observations. Use lbb_query with SPARQL property paths for precise path selection.",
7
10
  inputSchema: inspectWireSchema,
8
11
  annotations: READ_ONLY,
9
12
  }, (rawArgs) => {
@@ -11,19 +14,22 @@ export function registerLbbTools(server, client) {
11
14
  if (!parsed.success)
12
15
  return errorResult(parsed.error);
13
16
  const args = parsed.data;
17
+ if (args.action === "ontology" || args.action === "schema") {
18
+ return metadataPage(client, args)
19
+ .then(toolResult)
20
+ .catch(async (error) => errorResult(await enrichError(client, error)));
21
+ }
14
22
  return run(client, `lbb_inspect.${args.action}`, args.detail, () => {
15
23
  const target = scoped(client, args.graph, args.branch);
16
24
  switch (args.action) {
17
25
  case "guide":
18
26
  return guide(target);
19
- case "ontology":
20
- // Request per-relation edge counts so the listing flags which of the
21
- // declared relations are actually populated (edge_count: 0 = unused).
22
- return target.ontologyView({ counts: true });
27
+ case "graphs":
28
+ return target.listGraphs();
29
+ case "publication":
30
+ return target.publicationStatus();
23
31
  case "ontology_conformance":
24
32
  return target.ontologyConformance();
25
- case "schema":
26
- return target.schema.view();
27
33
  case "ontology_search":
28
34
  return target.ontologySearch({
29
35
  query: args.query,
@@ -115,6 +121,19 @@ export function registerLbbTools(server, client) {
115
121
  const branch = cursor?.branch ?? args.branch;
116
122
  const offset = cursor?.offset ?? 0;
117
123
  const target = scoped(client, graph, branch);
124
+ for (const key of [
125
+ "entailment",
126
+ "consistency",
127
+ "min_indexed_seq",
128
+ ]) {
129
+ if (cursor &&
130
+ args[key] !== undefined &&
131
+ args[key] !== cursor[key]) {
132
+ throw new Error(`cursor ${key} does not match the supplied ${key}`);
133
+ }
134
+ }
135
+ const consistency = cursor?.consistency ?? args.consistency;
136
+ const minIndexedSeq = cursor?.min_indexed_seq ?? args.min_indexed_seq;
118
137
  if (args.mode === "structured") {
119
138
  const body = (cursor?.body ?? args.body);
120
139
  if (body === undefined)
@@ -124,38 +143,43 @@ export function registerLbbTools(server, client) {
124
143
  stableJson(args.body) !== stableJson(cursor.body)) {
125
144
  throw new Error("cursor body does not match the supplied body argument");
126
145
  }
127
- // The body's valid-time field is `as_of_valid_time`; the server
128
- // ignores a bare `as_of` key, so a naive caller would chart
129
- // head-snapshot data and never know. Turn that silent no-op into a
130
- // clear error pointing at the right spelling.
131
- if (body.as_of !== undefined) {
132
- throw new Error("the structured body has an `as_of` key, which the server ignores — use the top-level `as_of` argument (valid-time, RFC3339) or rename it to `as_of_valid_time` inside the body");
146
+ if (args.as_of !== undefined ||
147
+ cursor?.as_of !== undefined ||
148
+ body.as_of !== undefined ||
149
+ body.as_of_valid_time !== undefined) {
150
+ throw new Error("structured SPARQL valid-time selectors are not supported; use as_of_commit_seq for a retained commit snapshot, or start a new query without the valid-time selector");
133
151
  }
134
- if (cursor &&
135
- args.as_of !== undefined &&
136
- args.as_of !== cursor.as_of) {
137
- throw new Error("cursor as_of does not match the supplied as_of argument");
152
+ for (const key of ["consistency", "min_indexed_seq"]) {
153
+ const requested = key === "consistency" ? consistency : minIndexedSeq;
154
+ if (requested !== undefined &&
155
+ body[key] !== undefined &&
156
+ requested !== body[key])
157
+ throw new Error(`body ${key} conflicts with the query's ${key}`);
158
+ }
159
+ // Resolve the top-level or body commit pin once and retain it
160
+ // across cursor pages. The API validates its exact RDF lineage.
161
+ if (body.as_of_commit_seq !== undefined &&
162
+ body.as_of_commit_seq !== null &&
163
+ (typeof body.as_of_commit_seq !== "number" ||
164
+ !Number.isSafeInteger(body.as_of_commit_seq) ||
165
+ body.as_of_commit_seq < 0)) {
166
+ throw new Error("body as_of_commit_seq must be a nonnegative safe integer");
138
167
  }
139
- // Commit-seq pin: top-level arg, else the body field, pinned for
140
- // continuation. Valid-time pin: cursor, else top-level arg, else the
141
- // body's `as_of_valid_time`. Both are resolved here and set
142
- // explicitly so the request never depends on the body's spelling.
143
168
  const requestedCommitSeq = args.as_of_commit_seq ??
144
169
  (typeof body.as_of_commit_seq === "number"
145
170
  ? body.as_of_commit_seq
146
171
  : undefined);
172
+ if (cursor &&
173
+ args.as_of_commit_seq !== undefined &&
174
+ args.as_of_commit_seq !== cursor.as_of_commit_seq) {
175
+ throw new Error("cursor as_of_commit_seq does not match the supplied as_of_commit_seq argument");
176
+ }
147
177
  const asOfCommitSeq = await queryCommitPin(target, requestedCommitSeq, cursor);
148
- const asOfValidTime = cursor?.as_of ??
149
- args.as_of ??
150
- (typeof body.as_of_valid_time === "string"
151
- ? body.as_of_valid_time
152
- : undefined);
153
178
  const request = {
154
179
  ...body,
155
180
  limit: rowLimit,
156
181
  offset,
157
182
  as_of_commit_seq: asOfCommitSeq,
158
- as_of_valid_time: asOfValidTime ?? null,
159
183
  };
160
184
  // The analytics route is gone; structured bodies run only on the
161
185
  // SPARQL-select path, which rejects unknown fields. Name the
@@ -165,7 +189,10 @@ export function registerLbbTools(server, client) {
165
189
  request.combinators.length > 0) {
166
190
  throw new Error("`combinators` (UNION/OPTIONAL/MINUS/EXISTS) is no longer accepted by structured mode; the analytics route was removed. Express the same query as SPARQL text with mode=sparql.");
167
191
  }
168
- const response = await target.sparql(request);
192
+ const response = await target.sparql(request, {
193
+ consistency,
194
+ minIndexedSeq,
195
+ });
169
196
  const rowPage = rowPageFrom(response);
170
197
  const cursorBase = {
171
198
  v: 1,
@@ -175,7 +202,8 @@ export function registerLbbTools(server, client) {
175
202
  detail,
176
203
  row_limit: rowLimit,
177
204
  body,
178
- as_of: asOfValidTime,
205
+ consistency,
206
+ min_indexed_seq: minIndexedSeq,
179
207
  as_of_commit_seq: asOfCommitSeq,
180
208
  };
181
209
  const next = rowPageNext(cursorBase, rowPage);
@@ -193,25 +221,23 @@ export function registerLbbTools(server, client) {
193
221
  normalizeLbbIris(args.query).query !== cursor.query) {
194
222
  throw new Error("cursor query does not match the supplied query argument");
195
223
  }
196
- if (cursor &&
197
- args.as_of !== undefined &&
198
- args.as_of !== cursor.as_of) {
199
- throw new Error("cursor as_of does not match the supplied as_of argument");
224
+ if (args.as_of !== undefined || cursor?.as_of !== undefined) {
225
+ throw new Error("SPARQL text valid-time as_of is not supported; use as_of_commit_seq for a retained commit snapshot, or start a new query without as_of");
200
226
  }
201
227
  if (cursor &&
202
228
  args.as_of_commit_seq !== undefined &&
203
229
  args.as_of_commit_seq !== cursor.as_of_commit_seq) {
204
230
  throw new Error("cursor as_of_commit_seq does not match the supplied as_of_commit_seq argument");
205
231
  }
206
- const asOf = cursor?.as_of ?? args.as_of;
207
232
  const asOfCommitSeq = await queryCommitPin(target, args.as_of_commit_seq, cursor);
233
+ const entailment = cursor?.entailment ?? args.entailment ?? "none";
208
234
  const response = await target.sparqlText({
209
235
  query,
210
- as_of_valid_time: asOf ?? null,
236
+ entailment,
211
237
  as_of_commit_seq: asOfCommitSeq ?? null,
212
238
  limit: rowLimit,
213
239
  offset,
214
- });
240
+ }, { consistency, minIndexedSeq });
215
241
  const data = JSON.parse(response.results);
216
242
  const rowPage = rowPageFrom(response);
217
243
  const cursorBase = {
@@ -222,7 +248,9 @@ export function registerLbbTools(server, client) {
222
248
  detail,
223
249
  row_limit: rowLimit,
224
250
  query,
225
- as_of: asOf,
251
+ entailment,
252
+ consistency,
253
+ min_indexed_seq: minIndexedSeq,
226
254
  as_of_commit_seq: asOfCommitSeq,
227
255
  };
228
256
  const next = rowPageNext(cursorBase, rowPage);
@@ -322,6 +350,10 @@ export function registerLbbTools(server, client) {
322
350
  "Register a field first with lbb_configure evolve_ontology add_property; " +
323
351
  "the commit response echoes written_properties so you can confirm what landed."),
324
352
  search_feedback: searchFeedbackSchema.optional(),
353
+ dry_run: z
354
+ .boolean()
355
+ .optional()
356
+ .describe("Validate a facts commit and return its structured SHACL report without writing. Only supported for mode=facts."),
325
357
  observed_at: z
326
358
  .string()
327
359
  .optional()
@@ -345,13 +377,15 @@ export function registerLbbTools(server, client) {
345
377
  ...graphScope,
346
378
  },
347
379
  annotations: IDEMPOTENT_WRITE,
348
- }, ({ idempotency_key, mode, triplets, entity_embeddings, entity_properties, search_feedback, observed_at, edge_idempotency, retract_edges, retract_entities, graph, branch, }) => run(client, "lbb_commit", "standard", () => {
380
+ }, ({ idempotency_key, mode, triplets, entity_embeddings, entity_properties, search_feedback, dry_run, observed_at, edge_idempotency, retract_edges, retract_entities, graph, branch, }) => run(client, "lbb_commit", "standard", () => {
349
381
  const commitMode = mode ??
350
382
  (search_feedback
351
383
  ? "search_feedback"
352
384
  : retract_edges || retract_entities
353
385
  ? "retract"
354
386
  : "facts");
387
+ if (dry_run && commitMode !== "facts")
388
+ throw new Error("dry_run is supported only for lbb_commit mode=facts");
355
389
  if (commitMode === "retract") {
356
390
  const edges = retract_edges ?? [];
357
391
  const entities = retract_entities ?? [];
@@ -387,12 +421,14 @@ export function registerLbbTools(server, client) {
387
421
  throw new Error("lbb_commit requires at least one triplet, entity embedding, or entity property");
388
422
  }
389
423
  const key = idempotency_key ?? contentHashKey({ graph, branch }, payload);
424
+ if (dry_run)
425
+ return scoped(client, graph, branch).commitDryRun(payload);
390
426
  return scoped(client, graph, branch).commit(payload, {
391
427
  idempotencyKey: key,
392
428
  });
393
429
  }));
394
430
  server.registerTool("lbb_configure", {
395
- description: "Mutate stored graph configuration. Actions: define_ontology, evolve_ontology, and publish_schema. Schema publication atomically activates metadata and enqueues durable conformance; it never validates the whole graph in the request.",
431
+ description: "Manage native schema metadata. Actions: define_ontology (friendly spec with super_types), evolve_ontology (ordered edits including add_super_types), publish_schema (SHACL activation). All support dry_run previews. Definition/import here extracts native metadata; it does NOT store the complete RDF/OWL document as queryable graph facts. Use lbb_rdf import for full OWL and lbb_rdf update for additive INSERT DATA revisions; RDF deletions are unsupported. Publish_schema accepts unchanged ontology plus shapes; use define/evolve for native ontology changes. Publication enqueues durable conformance; a preview does not validate the whole graph.",
396
432
  inputSchema: configureWireSchema,
397
433
  annotations: MUTATING,
398
434
  }, (rawArgs) => {
@@ -410,23 +446,24 @@ export function registerLbbTools(server, client) {
410
446
  source: args.source,
411
447
  format: args.format,
412
448
  merge_default: args.merge_default,
449
+ dry_run: args.dry_run,
413
450
  }));
414
451
  }
415
452
  if (args.action === "evolve_ontology") {
416
- return scoped(client, args.graph, args.branch).evolveOntology({
453
+ return scoped(client, args.graph, args.branch).ontology.evolve({
417
454
  ops: args.ops,
418
455
  allow_data_conflicts: args.allow_data_conflicts ?? false,
419
- });
456
+ }, { dryRun: args.dry_run });
420
457
  }
421
- if (args.ontology === undefined && args.shapes === undefined) {
422
- throw new Error("publish_schema requires an ontology or shapes source");
458
+ if (args.shapes === undefined) {
459
+ throw new Error("publish_schema requires a SHACL shapes source; use define_ontology or evolve_ontology for native metadata changes");
423
460
  }
424
461
  return scoped(client, args.graph, args.branch).schema.publish({
425
462
  ontology: args.ontology,
426
463
  shapes: args.shapes,
427
464
  desired_mode: args.desired_mode,
428
465
  confirm_restrictive: args.confirm_restrictive,
429
- });
466
+ }, { dryRun: args.dry_run });
430
467
  });
431
468
  });
432
469
  server.registerTool("lbb_branch", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/mcp",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for little big brain — graph and hybrid search tools for agents",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -52,7 +52,7 @@
52
52
  "test:coverage": "npm run clean:test && tsc -p tsconfig.test.json && node --test --experimental-test-coverage --test-coverage-include=test-dist/*.js --test-coverage-exclude=test-dist/*.test.js --test-coverage-exclude=test-dist/stdio.js --test-coverage-exclude=test-dist/test-support.js --test-coverage-lines=90 --test-coverage-branches=70 --test-coverage-functions=85 \"test-dist/**/*.test.js\""
53
53
  },
54
54
  "dependencies": {
55
- "@littlebigbrain/client": "^0.13.0",
55
+ "@littlebigbrain/client": "^0.13.1",
56
56
  "@modelcontextprotocol/sdk": "^1",
57
57
  "zod": "^3"
58
58
  },