@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 +97 -3
- package/dist/metadata-pages.d.ts +33 -0
- package/dist/metadata-pages.js +150 -0
- package/dist/rdf-tool.d.ts +3 -0
- package/dist/rdf-tool.js +81 -0
- package/dist/server.js +3 -1
- package/dist/tool-contracts.js +75 -6
- package/dist/tool-runtime.js +77 -96
- package/dist/tools.js +61 -16
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @littlebigbrain/mcp
|
|
2
2
|
|
|
3
|
-
|
|
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` |
|
|
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
|
|
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
|
+
}
|
package/dist/rdf-tool.js
ADDED
|
@@ -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
|
|
7
|
+
const server = new McpServer({ name: "lbb", version });
|
|
6
8
|
registerLbbTools(server, client);
|
|
7
9
|
return server;
|
|
8
10
|
}
|
package/dist/tool-contracts.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
454
|
-
relations:
|
|
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("
|
|
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(),
|
package/dist/tool-runtime.js
CHANGED
|
@@ -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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 "
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return target.
|
|
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: "
|
|
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).
|
|
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.
|
|
414
|
-
throw new Error("publish_schema requires
|
|
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.
|
|
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.
|
|
55
|
+
"@littlebigbrain/client": "^0.13.1",
|
|
56
56
|
"@modelcontextprotocol/sdk": "^1",
|
|
57
57
|
"zod": "^3"
|
|
58
58
|
},
|