@littlebigbrain/mcp 0.4.4 → 0.5.1

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,22 +58,102 @@ 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
+ Query pages preserve complete RDF values and may contain fewer than `row_limit`
73
+ rows to fit the 80 KB UTF-8 output budget. Follow the returned `next` arguments
74
+ until absent; the cursor advances by rows actually delivered. A single row that
75
+ exceeds the budget fails explicitly: project fewer fields or use the direct
76
+ SPARQL HTTP endpoint for that row.
77
+
78
+
71
79
  Both `lbb_query` SPARQL modes (`sparql` and `structured`) support retained commit reads
72
80
  through `as_of_commit_seq`. When omitted, the connector pins the current head
73
81
  commit and reuses it for cursor pages. Valid-time `as_of` is unsupported and is
74
82
  rejected before an API call, including when carried in an old cursor. Start a
75
83
  new query without that selector or choose a retained commit sequence.
76
84
 
85
+ ## Create and evolve an ontology through MCP
86
+
87
+ Start with `lbb_inspect action=guide`; `action=graphs` helps select an existing
88
+ scope, and a missing graph returns bootstrap guidance. Decide what questions
89
+ the graph must answer before choosing classes and relations. Distinguish
90
+ source-backed facts from hypotheses, and preserve evidence and dates.
91
+
92
+ Native metadata and stored RDF axioms are separate:
93
+
94
+ - `lbb_configure action=define_ontology` accepts a friendly `spec`, including
95
+ class `super_types`. Unknown spec fields fail explicitly. `lbb_json` expects
96
+ an internal serialized ontology, not a friendly spec. Raw OWL supplied to
97
+ configure is reduced to native metadata; it is not stored as a full document.
98
+ - `lbb_rdf action=import` stores the complete Turtle, N-Triples, N-Quads, or TriG
99
+ document as queryable graph facts, including RDF lists, annotations, and OWL
100
+ axioms. Pass `source`; the published RDF tier supports only the default RDF
101
+ graph. Dataset formats must contain only default-graph quads. The first RDF
102
+ data write selects RDF-native storage, which refuses
103
+ later property-graph commits; choose the write workflow before bootstrap.
104
+ - `lbb_configure action=evolve_ontology` supports explicit native changes,
105
+ including `add_super_types`. `dry_run: true` previews define/evolve/publish;
106
+ the same flag previews `lbb_commit mode=facts` without writing.
107
+ - `lbb_rdf action=update` submits SPARQL Update unchanged; currently only
108
+ `INSERT DATA` is supported. DELETE, WHERE, and graph replacement are refused.
109
+ Re-importing is
110
+ additive and does not remove obsolete axioms. Content-based retry keys are
111
+ automatic; use a new explicit key for an intentional repeat after other edits.
112
+
113
+ For example, add a superclass without a browser or RDF conversion:
114
+
115
+ ```json
116
+ {
117
+ "action": "update",
118
+ "update": "INSERT DATA { <urn:Person> <http://www.w3.org/2000/01/rdf-schema#subClassOf> <urn:Contact> }"
119
+ }
120
+ ```
121
+
122
+ Removing or replacing RDF axioms requires native bounded update support in the
123
+ engine. Until then, import a revised document into a new versioned LBB graph,
124
+ verify it, and explicitly switch consumers. Do not implicitly delete the original.
125
+
126
+ `publish_schema` activates SHACL shapes against unchanged native metadata.
127
+ Its preview checks parsing and compatibility without writing objects or
128
+ scheduling jobs; it does not audit the entire graph. Preview restrictive
129
+ native edits with evolve, resolve conflicts, then apply. For restrictive
130
+ SHACL, use warn → inspect conformance → repair → reject.
131
+
132
+ After applying, inspect `action=publication`, then verify both asserted axioms
133
+ and expected inferred answers. `lbb_query mode=sparql` accepts explicit
134
+ `entailment: "none" | "subclass" | "rdfs" | "owl"` (default `none`),
135
+ `consistency: "eventual" | "strong"`, and `min_indexed_seq`. Cursors retain
136
+ these controls. OWL is the server's supported inference profile, not arbitrary
137
+ OWL DL. An upload acknowledgement is not proof of successful reasoning.
138
+
139
+ To read asserted axioms in the default graph, query with `entailment: "none"`
140
+ and follow all returned row cursors:
141
+
142
+ ```sparql
143
+ SELECT ?s ?p ?o WHERE {
144
+ ?s ?p ?o
145
+ } ORDER BY ?s ?p ?o
146
+ ```
147
+
148
+ `lbb_inspect action=ontology` and `action=schema` page complete native metadata
149
+ with `page_size` (default 50, maximum 500), optional `section`, and `cursor`.
150
+ Pass the returned `next` arguments until absent. These pages preserve nested
151
+ values even with `detail=compact`; they never replace the remainder with a
152
+ suggestion to repeat `detail=full`. If a single entry is too large, the result
153
+ contains `entry_fragment`: concatenate `serialized_json` by `char_offset`, then
154
+ JSON-parse the completed entry. Changed metadata invalidates the cursor rather
155
+ than mixing versions. Restart inspection after applying edits.
156
+
77
157
  ## Embed the server
78
158
 
79
159
  For self-hosting behind your own auth, the package also serves the tools over HTTP:
@@ -91,3 +171,17 @@ createMcpHttpServer({
91
171
  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.
92
172
 
93
173
  Full tool schemas and examples: [docs.littlebigbrain.com/sdks/mcp](https://docs.littlebigbrain.com/sdks/mcp/).
174
+
175
+ ## Local end-to-end ontology check
176
+
177
+ From the repository root, build the server and SDKs, then opt into the isolated
178
+ real-server MCP test (it creates and removes its own temporary data root):
179
+
180
+ ```sh
181
+ cargo build -p lbb-server
182
+ npm run build -w @littlebigbrain/client
183
+ LBB_TEST_SERVER_BIN="$PWD/target/debug/lbb-server" npm test -w @littlebigbrain/mcp
184
+ ```
185
+
186
+ The test verifies native hierarchy evolution, preservation of RDF annotations,
187
+ 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). ' +
@@ -409,6 +461,11 @@ 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()
@@ -448,10 +505,14 @@ export const configureInputSchema = z.discriminatedUnion("action", [
448
505
  z
449
506
  .object({
450
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."),
451
512
  graph: z.string().describe("Graph to create or redefine"),
452
513
  branch: graphScope.branch,
453
- entity_types: jsonObjectArraySchema.optional(),
454
- relations: jsonObjectArraySchema.optional(),
514
+ entity_types: z.array(z.union([z.string(), jsonObjectSchema])).optional(),
515
+ relations: z.array(z.union([z.string(), jsonObjectSchema])).optional(),
455
516
  source: z.string().optional(),
456
517
  format: ontologyFormatSchema.optional(),
457
518
  merge_default: z.boolean().optional(),
@@ -460,6 +521,10 @@ export const configureInputSchema = z.discriminatedUnion("action", [
460
521
  z
461
522
  .object({
462
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."),
463
528
  ontology: ontologySourceSchema.optional(),
464
529
  shapes: shapeSourceSchema.optional(),
465
530
  desired_mode: schemaModeSchema.optional(),
@@ -470,6 +535,10 @@ export const configureInputSchema = z.discriminatedUnion("action", [
470
535
  z
471
536
  .object({
472
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."),
473
542
  ops: z
474
543
  .array(ontologyEvolveOpSchema)
475
544
  .min(1)
@@ -477,7 +546,7 @@ export const configureInputSchema = z.discriminatedUnion("action", [
477
546
  allow_data_conflicts: z
478
547
  .boolean()
479
548
  .optional()
480
- .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."),
481
550
  ...graphScope,
482
551
  })
483
552
  .strict(),
@@ -312,104 +312,70 @@ export function envelope(label, value, detailArg, next) {
312
312
  };
313
313
  }
314
314
  export function queryEnvelope(label, value, detailArg, rowPage, next, repage) {
315
- const detail = normalizeDetail(detailArg);
316
- const limits = compactLimits(detail);
317
- const state = { truncated: false };
318
- const returned = rowPage?.returned;
319
- const rowCap = rowPage
320
- ? Math.max(limits.maxItems, rowPage.returned)
321
- : limits.maxItems;
322
- let data = truncateValue(value, { ...limits, maxItems: rowCap }, state);
323
- const partialRows = rowPage
324
- ? rowPage.returned < rowPage.total || rowPage.has_more
325
- : false;
326
- const rowText = rowPage
327
- ? rowPage.returned < rowPage.total
328
- ? `returned ${rowPage.returned} of ${rowPage.total} rows`
329
- : `returned ${rowPage.returned} rows`
330
- : undefined;
331
- const serverFlags = serverTruncationFlags(value);
332
- const serverTruncated = serverFlags.length > 0;
333
- const serverText = serverTruncated
334
- ? ` [server-truncated: ${serverFlags.join(", ")}]`
335
- : "";
336
- let result = {
337
- summary: rowText
338
- ? `${label}: ${rowText}${serverText}${state.truncated ? " [truncated output]" : ""}`
339
- : defaultSummary(label, value, state.truncated),
340
- data,
341
- counts: countsFor(value),
342
- row_page: rowPage,
343
- truncated: state.truncated || partialRows || serverTruncated || undefined,
344
- next: partialRows && next
345
- ? next
346
- : state.truncated && nextDetail(detail)
347
- ? { detail: nextDetail(detail) }
348
- : next,
349
- };
350
- let text = JSON.stringify(result, null, 2);
351
- if (text.length <= HARD_OUTPUT_CHARS)
352
- return result;
353
- // The full result overflows one MCP tool result, so the displayed rows are
354
- // capped to fit. `row_page`/`counts` still describe the *server* page, so
355
- // reporting only those reads as "every row delivered" even when the display
356
- // was cut — the recurring MCP false-positive. So: state shown-vs-returned
357
- // explicitly (`rows_shown`), and give advice that matches reality —
358
- // * server itself withheld rows (partialRows): page with the existing cursor;
359
- // * server returned the complete set but it is too big: page the same set at
360
- // a smaller row_limit via a fresh cursor (offered here as `next`) or narrow
361
- // with HAVING.
362
- // "page with the cursor" is never suggested unless a cursor is actually given.
363
- const remedy = partialRows
364
- ? " page with the cursor for the remaining rows"
365
- : repage
366
- ? " re-run with the returned cursor to page the full set at a smaller row_limit, or add a HAVING filter to narrow the groups"
367
- : " re-run with a lower row_limit to page the full set, or add a HAVING filter to narrow the groups";
368
- const capNote = (shown) => returned !== undefined && shown < returned
369
- ? ` [MCP showed ${shown} of ${returned} rows — over the ${HARD_OUTPUT_CHARS}-char output budget]`
370
- : " [hard-capped for MCP output]";
371
- for (const cap of [200, 100, 50, 25, 10, 5, 3]) {
372
- const hardState = { truncated: true };
373
- data = truncateValue(value, { maxItems: cap, maxString: 160 }, hardState);
374
- const shown = returned !== undefined ? Math.min(cap, returned) : cap;
375
- const hardNext = partialRows
376
- ? next
377
- : repage
378
- ? continuationNext(repage, shown, Math.max(1, shown))
315
+ // A query page is data, not a preview: keep entire terms/values and page
316
+ // the rows themselves to fit the wire budget. The cursor must advance by
317
+ // what the caller actually received, even inside a partial server page.
318
+ const source = value;
319
+ const results = source?.results;
320
+ const key = Array.isArray(results?.bindings)
321
+ ? "bindings"
322
+ : Array.isArray(source?.groups) && source.groups.length > 0
323
+ ? "groups"
324
+ : Array.isArray(source?.solutions)
325
+ ? "solutions"
379
326
  : undefined;
380
- result = {
381
- summary: rowText
382
- ? `${label}: ${rowText}${serverText}${capNote(shown)} —${remedy}`
383
- : `${label}${capNote(shown)} —${remedy}`,
384
- data,
385
- counts: countsFor(value),
386
- row_page: rowPage,
387
- rows_shown: returned !== undefined ? shown : undefined,
388
- truncated: true,
389
- next: hardNext,
327
+ const rows = key === "bindings" ? results?.bindings : key ? source[key] : undefined;
328
+ if (!rowPage || !repage || !Array.isArray(rows)) {
329
+ return { ...envelope(label, value, detailArg, next), row_page: rowPage };
330
+ }
331
+ if (rows.length !== rowPage.returned) {
332
+ throw new Error("Query row count does not match its page; refusing a cursor that could skip rows.");
333
+ }
334
+ const flags = serverTruncationFlags(value);
335
+ const build = (count) => {
336
+ const hasMore = count < rows.length || rowPage.has_more;
337
+ const page = {
338
+ ...rowPage,
339
+ returned: count,
340
+ has_more: hasMore,
341
+ next_offset: hasMore ? rowPage.offset + count : undefined,
390
342
  };
391
- text = JSON.stringify(result, null, 2);
392
- if (text.length <= HARD_OUTPUT_CHARS)
393
- return result;
394
- }
395
- return {
396
- summary: rowText
397
- ? `${label}: ${rowText}${serverText}${capNote(0)} —${remedy}`
398
- : `${label}${capNote(0)} —${remedy}`,
399
- data: {
400
- note: "The response was too large for the MCP tool result even at the minimum row cap. Page with a smaller row_limit or add a HAVING filter to narrow the groups.",
401
- preview: text.slice(0, 20_000),
402
- },
403
- counts: countsFor(value),
404
- row_page: rowPage,
405
- rows_shown: returned !== undefined ? 0 : undefined,
406
- truncated: true,
407
- next: partialRows
408
- ? next
409
- : repage
410
- ? continuationNext(repage, 0, 1)
343
+ const selected = rows.slice(0, count);
344
+ const data = key === "bindings"
345
+ ? { ...source, results: { ...results, bindings: selected } }
346
+ : { ...source, [key]: selected, row_page: page };
347
+ return {
348
+ summary: `${label}: returned ${count}${count < page.total ? ` of ${page.total}` : ""} rows${flags.length ? ` [server-truncated: ${flags.join(", ")}]` : ""}${count < rows.length ? " [byte-bounded page; continue with the cursor]" : ""}`,
349
+ data,
350
+ counts: countsFor(data),
351
+ row_page: page,
352
+ rows_shown: count < rows.length ? count : undefined,
353
+ truncated: hasMore || flags.length > 0 || undefined,
354
+ next: hasMore
355
+ ? continuationNext(repage, page.offset + count, repage.row_limit)
411
356
  : undefined,
357
+ };
412
358
  };
359
+ // Leave room for tool-level normalization notes added by the caller. Count
360
+ // UTF-8 bytes, including multibyte literals, not JavaScript code units.
361
+ const fits = (page) => Buffer.byteLength(JSON.stringify(page, null, 2), "utf8") <=
362
+ HARD_OUTPUT_CHARS - 2048;
363
+ const full = build(rows.length);
364
+ if (fits(full))
365
+ return full;
366
+ let lo = 0;
367
+ let hi = rows.length;
368
+ while (lo < hi) {
369
+ const mid = Math.ceil((lo + hi) / 2);
370
+ if (fits(build(mid)))
371
+ lo = mid;
372
+ else
373
+ hi = mid - 1;
374
+ }
375
+ if (lo === 0) {
376
+ throw new Error("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
+ }
378
+ return build(lo);
413
379
  }
414
380
  export function toolResult(value) {
415
381
  return {
@@ -666,7 +632,20 @@ export function buildPossibilities(relations) {
666
632
  return possibilities;
667
633
  }
668
634
  export async function guide(scopedClient) {
669
- const s = (await scopedClient.summary());
635
+ let summary;
636
+ try {
637
+ summary = await scopedClient.summary();
638
+ }
639
+ catch (error) {
640
+ if (!(error instanceof LbbError) || error.code !== "graph_not_found")
641
+ throw error;
642
+ return {
643
+ graph_exists: false,
644
+ graphs: await scopedClient.listGraphs(),
645
+ 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.",
646
+ };
647
+ }
648
+ const s = summary;
670
649
  const entityTypes = [...(s.entity_types ?? [])].sort((a, b) => b.count - a.count);
671
650
  const relations = [...(s.relations ?? [])].sort((a, b) => b.count - a.count);
672
651
  return {
@@ -684,7 +663,7 @@ export async function guide(scopedClient) {
684
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 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
664
  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
665
  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.",
666
+ 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
667
  },
689
668
  possibilities: buildPossibilities(relations),
690
669
  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 +759,7 @@ export function ontologyDefineBody(p) {
780
759
  source: p.source,
781
760
  format: p.format ?? "auto",
782
761
  merge_default: p.merge_default ?? false,
762
+ ...(p.dry_run !== undefined ? { dry_run: p.dry_run } : {}),
783
763
  };
784
764
  }
785
765
  if (!p.entity_types?.length || !p.relations?.length) {
@@ -792,6 +772,7 @@ export function ontologyDefineBody(p) {
792
772
  }),
793
773
  format: "spec",
794
774
  merge_default: p.merge_default ?? false,
775
+ ...(p.dry_run !== undefined ? { dry_run: p.dry_run } : {}),
795
776
  };
796
777
  }
797
778
  /**
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)
@@ -130,6 +149,13 @@ export function registerLbbTools(server, client) {
130
149
  body.as_of_valid_time !== undefined) {
131
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");
132
151
  }
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
+ }
133
159
  // Resolve the top-level or body commit pin once and retain it
134
160
  // across cursor pages. The API validates its exact RDF lineage.
135
161
  if (body.as_of_commit_seq !== undefined &&
@@ -163,7 +189,10 @@ export function registerLbbTools(server, client) {
163
189
  request.combinators.length > 0) {
164
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.");
165
191
  }
166
- const response = await target.sparql(request);
192
+ const response = await target.sparql(request, {
193
+ consistency,
194
+ minIndexedSeq,
195
+ });
167
196
  const rowPage = rowPageFrom(response);
168
197
  const cursorBase = {
169
198
  v: 1,
@@ -173,6 +202,8 @@ export function registerLbbTools(server, client) {
173
202
  detail,
174
203
  row_limit: rowLimit,
175
204
  body,
205
+ consistency,
206
+ min_indexed_seq: minIndexedSeq,
176
207
  as_of_commit_seq: asOfCommitSeq,
177
208
  };
178
209
  const next = rowPageNext(cursorBase, rowPage);
@@ -199,12 +230,14 @@ export function registerLbbTools(server, client) {
199
230
  throw new Error("cursor as_of_commit_seq does not match the supplied as_of_commit_seq argument");
200
231
  }
201
232
  const asOfCommitSeq = await queryCommitPin(target, args.as_of_commit_seq, cursor);
233
+ const entailment = cursor?.entailment ?? args.entailment ?? "none";
202
234
  const response = await target.sparqlText({
203
235
  query,
236
+ entailment,
204
237
  as_of_commit_seq: asOfCommitSeq ?? null,
205
238
  limit: rowLimit,
206
239
  offset,
207
- });
240
+ }, { consistency, minIndexedSeq });
208
241
  const data = JSON.parse(response.results);
209
242
  const rowPage = rowPageFrom(response);
210
243
  const cursorBase = {
@@ -215,6 +248,9 @@ export function registerLbbTools(server, client) {
215
248
  detail,
216
249
  row_limit: rowLimit,
217
250
  query,
251
+ entailment,
252
+ consistency,
253
+ min_indexed_seq: minIndexedSeq,
218
254
  as_of_commit_seq: asOfCommitSeq,
219
255
  };
220
256
  const next = rowPageNext(cursorBase, rowPage);
@@ -314,6 +350,10 @@ export function registerLbbTools(server, client) {
314
350
  "Register a field first with lbb_configure evolve_ontology add_property; " +
315
351
  "the commit response echoes written_properties so you can confirm what landed."),
316
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."),
317
357
  observed_at: z
318
358
  .string()
319
359
  .optional()
@@ -337,13 +377,15 @@ export function registerLbbTools(server, client) {
337
377
  ...graphScope,
338
378
  },
339
379
  annotations: IDEMPOTENT_WRITE,
340
- }, ({ 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", () => {
341
381
  const commitMode = mode ??
342
382
  (search_feedback
343
383
  ? "search_feedback"
344
384
  : retract_edges || retract_entities
345
385
  ? "retract"
346
386
  : "facts");
387
+ if (dry_run && commitMode !== "facts")
388
+ throw new Error("dry_run is supported only for lbb_commit mode=facts");
347
389
  if (commitMode === "retract") {
348
390
  const edges = retract_edges ?? [];
349
391
  const entities = retract_entities ?? [];
@@ -379,12 +421,14 @@ export function registerLbbTools(server, client) {
379
421
  throw new Error("lbb_commit requires at least one triplet, entity embedding, or entity property");
380
422
  }
381
423
  const key = idempotency_key ?? contentHashKey({ graph, branch }, payload);
424
+ if (dry_run)
425
+ return scoped(client, graph, branch).commitDryRun(payload);
382
426
  return scoped(client, graph, branch).commit(payload, {
383
427
  idempotencyKey: key,
384
428
  });
385
429
  }));
386
430
  server.registerTool("lbb_configure", {
387
- 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.",
388
432
  inputSchema: configureWireSchema,
389
433
  annotations: MUTATING,
390
434
  }, (rawArgs) => {
@@ -402,23 +446,24 @@ export function registerLbbTools(server, client) {
402
446
  source: args.source,
403
447
  format: args.format,
404
448
  merge_default: args.merge_default,
449
+ dry_run: args.dry_run,
405
450
  }));
406
451
  }
407
452
  if (args.action === "evolve_ontology") {
408
- return scoped(client, args.graph, args.branch).evolveOntology({
453
+ return scoped(client, args.graph, args.branch).ontology.evolve({
409
454
  ops: args.ops,
410
455
  allow_data_conflicts: args.allow_data_conflicts ?? false,
411
- });
456
+ }, { dryRun: args.dry_run });
412
457
  }
413
- if (args.ontology === undefined && args.shapes === undefined) {
414
- 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");
415
460
  }
416
461
  return scoped(client, args.graph, args.branch).schema.publish({
417
462
  ontology: args.ontology,
418
463
  shapes: args.shapes,
419
464
  desired_mode: args.desired_mode,
420
465
  confirm_restrictive: args.confirm_restrictive,
421
- });
466
+ }, { dryRun: args.dry_run });
422
467
  });
423
468
  });
424
469
  server.registerTool("lbb_branch", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/mcp",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
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
  },