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