@littlebigbrain/mcp 0.9.0 → 0.10.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 +14 -2
- package/dist/question.d.ts +27 -1
- package/dist/question.js +125 -10
- package/dist/tool-contracts.js +76 -1
- package/dist/tool-runtime.js +1 -1
- package/dist/tools.js +32 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -118,7 +118,19 @@ The server selects the kind of query, writes the SPARQL query from a
|
|
|
118
118
|
description of the graph, checks it, and runs it. The result holds the route,
|
|
119
119
|
the rationale, the query, and its rows. `next` continues the same query with
|
|
120
120
|
`mode: "sparql"`. Each question uses model tokens and counts toward a daily
|
|
121
|
-
limit of the stack, so the tool does not retry a failed call.
|
|
121
|
+
limit of the stack, so the tool does not retry a failed call. The server finds
|
|
122
|
+
the names in the question in the graph and lists them in `linked`. When the
|
|
123
|
+
question is about records you already know, pass their IRIs in `anchor` (at
|
|
124
|
+
most 10): the query uses them directly. A question about a date reads the last
|
|
125
|
+
commit written by the end of that day; pass `timeline` (`[{date,
|
|
126
|
+
as_of_commit_seq, label?}]`) when the commits stand for other dates. A
|
|
127
|
+
question that asks what changed returns `history.added` and
|
|
128
|
+
`history.removed`, the rows that differ.
|
|
129
|
+
|
|
130
|
+
A question can take some seconds. When the tool call carries a
|
|
131
|
+
`_meta.progressToken`, the server sends a progress notification for each
|
|
132
|
+
step, for example `Route: lookup (router, 0.92)` or `12 rows in 85 ms`. The
|
|
133
|
+
result is the same. Cancel the call to stop the server's work.
|
|
122
134
|
|
|
123
135
|
For your own data, start with `lbb_inspect` using `action: "guide"` or
|
|
124
136
|
`action: "ontology"`. Imported Resource Description Framework (RDF) data keeps
|
|
@@ -135,7 +147,7 @@ its original identifiers; inspect it with SPARQL when choosing query predicates.
|
|
|
135
147
|
| `lbb_embeddings_manage` | Set up or refresh embeddings, or change the embedding model. |
|
|
136
148
|
| `lbb_embeddings_delete` | Delete an embedding and its stored vectors. |
|
|
137
149
|
| `lbb_commit` | Write or retract JSON facts, or record search feedback. |
|
|
138
|
-
| `lbb_configure` | Define record types and relationships, list and apply ontology starters (CRM, documents, work),
|
|
150
|
+
| `lbb_configure` | Define record types and relationships, list and apply ontology starters (CRM, documents, work), publish validation rules, or keep the graph's notes and worked examples for questions in plain words (`get_rewrite_profile`, `set_rewrite_profile`). |
|
|
139
151
|
| `lbb_evals` | Label query results and check whether later queries return the expected answers. Read the model checks of a graph, and agree with or correct a check that the user confirmed. |
|
|
140
152
|
| `lbb_models` | Compare retrieval settings, read model training datasets, or read a month of managed-model activity. |
|
|
141
153
|
|
package/dist/question.d.ts
CHANGED
|
@@ -1,20 +1,46 @@
|
|
|
1
1
|
import type { LbbClient } from "@littlebigbrain/client";
|
|
2
2
|
type QuestionRoute = "lookup" | "aggregate" | "search" | "history" | "schema" | "unanswerable";
|
|
3
|
+
/** A date and the commit that holds the graph as it was then. */
|
|
4
|
+
interface TimelinePoint {
|
|
5
|
+
date: string;
|
|
6
|
+
as_of_commit_seq: number;
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
3
9
|
export interface QuestionArgs {
|
|
4
10
|
question: string;
|
|
5
11
|
context?: string;
|
|
6
12
|
route?: QuestionRoute;
|
|
7
13
|
limit?: number;
|
|
8
14
|
run?: boolean;
|
|
15
|
+
anchor?: string[];
|
|
16
|
+
timeline?: TimelinePoint[];
|
|
9
17
|
detail?: string;
|
|
10
18
|
graph?: string;
|
|
11
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Progress of one question: the tool call asked for it with a
|
|
22
|
+
* `_meta.progressToken`. `report` sends one MCP progress notification.
|
|
23
|
+
*/
|
|
24
|
+
export interface QuestionProgress {
|
|
25
|
+
/** Stops the server's work when the tool call is cancelled. */
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
report(progress: number, message: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** One event of a streamed rewrite, as this tool reads it. */
|
|
30
|
+
type StreamEvent = {
|
|
31
|
+
event: string;
|
|
32
|
+
data: unknown;
|
|
33
|
+
};
|
|
34
|
+
/** The progress message of a stream event, in short plain words. */
|
|
35
|
+
export declare function progressMessage(event: StreamEvent): string | undefined;
|
|
12
36
|
/**
|
|
13
37
|
* `lbb_query mode=question`: the server turns the question into a SPARQL
|
|
14
38
|
* query and, by default, runs it. The rows are bounded as `mode=sparql`
|
|
15
39
|
* bounds them, and `next` continues the same query with `mode=sparql`.
|
|
40
|
+
* With `progress`, the server streams its steps and each step is reported;
|
|
41
|
+
* the result is the same.
|
|
16
42
|
*/
|
|
17
|
-
export declare function answerQuestion(client: LbbClient, args: QuestionArgs, textFormat?: "compact" | "pretty"): Promise<{
|
|
43
|
+
export declare function answerQuestion(client: LbbClient, args: QuestionArgs, textFormat?: "compact" | "pretty", progress?: QuestionProgress): Promise<{
|
|
18
44
|
content: {
|
|
19
45
|
type: "text";
|
|
20
46
|
text: string;
|
package/dist/question.js
CHANGED
|
@@ -1,11 +1,118 @@
|
|
|
1
1
|
import { QUESTION_MAX_ROWS } from "./tool-contracts.js";
|
|
2
2
|
import { defaultRowLimit, normalizeDetail, queryEnvelope, queryToolResult, rowPageFrom, rowPageNext, scoped, } from "./tool-runtime.js";
|
|
3
|
+
const REPAIR_ERROR_CHARS = 200;
|
|
4
|
+
/** The progress message of a stream event, in short plain words. */
|
|
5
|
+
export function progressMessage(event) {
|
|
6
|
+
const data = (event.data ?? {});
|
|
7
|
+
switch (event.event) {
|
|
8
|
+
case "grounding":
|
|
9
|
+
return "Read the graph description";
|
|
10
|
+
case "route": {
|
|
11
|
+
const confidence = typeof data.confidence === "number"
|
|
12
|
+
? `, ${data.confidence.toFixed(2)}`
|
|
13
|
+
: "";
|
|
14
|
+
return `Route: ${String(data.kind)} (${String(data.by)}${confidence})`;
|
|
15
|
+
}
|
|
16
|
+
case "query":
|
|
17
|
+
return `Wrote query ${String(data.attempt ?? 1)}`;
|
|
18
|
+
case "run":
|
|
19
|
+
return data.point === "before"
|
|
20
|
+
? "Running the query at the earlier point"
|
|
21
|
+
: data.point === "after"
|
|
22
|
+
? "Running the query at the later point"
|
|
23
|
+
: "Running the query";
|
|
24
|
+
case "rows": {
|
|
25
|
+
const count = Number(data.count);
|
|
26
|
+
return `${count} ${count === 1 ? "row" : "rows"} in ${String(data.ms)} ms`;
|
|
27
|
+
}
|
|
28
|
+
case "repair": {
|
|
29
|
+
const error = String(data.error ?? "");
|
|
30
|
+
const shown = error.length > REPAIR_ERROR_CHARS
|
|
31
|
+
? `${error.slice(0, REPAIR_ERROR_CHARS - 1)}…`
|
|
32
|
+
: error;
|
|
33
|
+
return `Correcting the query: ${shown}`;
|
|
34
|
+
}
|
|
35
|
+
default:
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The rewrite as a stream of progress events. Each event becomes one
|
|
41
|
+
* progress notification; the `done` event is the same response as the call
|
|
42
|
+
* without a stream. A client release without `query.rewriteStream` answers
|
|
43
|
+
* without progress.
|
|
44
|
+
*/
|
|
45
|
+
async function streamedRewrite(target, body, progress) {
|
|
46
|
+
const query = target.query;
|
|
47
|
+
if (typeof query.rewriteStream !== "function")
|
|
48
|
+
return undefined;
|
|
49
|
+
let step = 0;
|
|
50
|
+
for await (const event of query.rewriteStream.call(target.query, body, {
|
|
51
|
+
signal: progress.signal,
|
|
52
|
+
})) {
|
|
53
|
+
if (event.event === "done")
|
|
54
|
+
return event.data;
|
|
55
|
+
const message = progressMessage(event);
|
|
56
|
+
if (message === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
step += 1;
|
|
59
|
+
// A lost notification never fails the question.
|
|
60
|
+
await progress.report(step, message).catch(() => undefined);
|
|
61
|
+
}
|
|
62
|
+
throw new Error("the rewrite stream ended before its done event");
|
|
63
|
+
}
|
|
64
|
+
/** A row of a comparison as `{ variable: lexical value }`. */
|
|
65
|
+
function lexicalRow(row) {
|
|
66
|
+
return Object.fromEntries(Object.entries(row).map(([name, term]) => [name, term.value]));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The history the tool returns: where the question read the graph, and for
|
|
70
|
+
* a comparison the rows that differ as lexical values. The two runs stay
|
|
71
|
+
* out: the later one is the result, and the rows that differ say what the
|
|
72
|
+
* earlier one held.
|
|
73
|
+
*/
|
|
74
|
+
function historyView(history) {
|
|
75
|
+
const view = { ...history };
|
|
76
|
+
delete view.before;
|
|
77
|
+
delete view.after;
|
|
78
|
+
if (history.added)
|
|
79
|
+
view.added = history.added.map(lexicalRow);
|
|
80
|
+
if (history.removed)
|
|
81
|
+
view.removed = history.removed.map(lexicalRow);
|
|
82
|
+
return view;
|
|
83
|
+
}
|
|
84
|
+
/** What the agent should know about the history of the answer. */
|
|
85
|
+
function historyNotes(history) {
|
|
86
|
+
if (!history)
|
|
87
|
+
return [];
|
|
88
|
+
const notes = [];
|
|
89
|
+
const point = typeof history.as_of_commit_seq === "number"
|
|
90
|
+
? `commit ${history.as_of_commit_seq}${history.label ? ` (${history.label})` : ""}`
|
|
91
|
+
: undefined;
|
|
92
|
+
if (history.as_of_date && point) {
|
|
93
|
+
const by = history.resolved_by === "timeline"
|
|
94
|
+
? "the timeline"
|
|
95
|
+
: history.resolved_by === "commit_time"
|
|
96
|
+
? "the commit times"
|
|
97
|
+
: "the request";
|
|
98
|
+
notes.push(`The question names ${history.as_of_date}; the server read ${point}, found by ${by}.`);
|
|
99
|
+
}
|
|
100
|
+
else if (history.as_of_date) {
|
|
101
|
+
notes.push(`The server could not place ${history.as_of_date} (see error). Pass timeline=[{date, as_of_commit_seq}] to name the commit of each date, or run the query with lbb_query mode=sparql as_of_commit_seq=<commit>.`);
|
|
102
|
+
}
|
|
103
|
+
if (history.compare && history.added && point) {
|
|
104
|
+
notes.push(`A comparison: the rows are the later run; history.added and history.removed hold the rows that changed since ${point}.${history.truncated ? " The difference is not complete: a run returned only its first rows, or more than 500 rows changed." : ""}`);
|
|
105
|
+
}
|
|
106
|
+
return notes;
|
|
107
|
+
}
|
|
3
108
|
/**
|
|
4
109
|
* `lbb_query mode=question`: the server turns the question into a SPARQL
|
|
5
110
|
* query and, by default, runs it. The rows are bounded as `mode=sparql`
|
|
6
111
|
* bounds them, and `next` continues the same query with `mode=sparql`.
|
|
112
|
+
* With `progress`, the server streams its steps and each step is reported;
|
|
113
|
+
* the result is the same.
|
|
7
114
|
*/
|
|
8
|
-
export async function answerQuestion(client, args, textFormat) {
|
|
115
|
+
export async function answerQuestion(client, args, textFormat, progress) {
|
|
9
116
|
const detail = normalizeDetail(args.detail);
|
|
10
117
|
const run = args.run ?? true;
|
|
11
118
|
const rowLimit = Math.min(args.limit ?? defaultRowLimit(detail), QUESTION_MAX_ROWS);
|
|
@@ -16,14 +123,17 @@ export async function answerQuestion(client, args, textFormat) {
|
|
|
16
123
|
...(args.route !== undefined ? { route: args.route } : {}),
|
|
17
124
|
run,
|
|
18
125
|
...(run ? { limit: rowLimit } : {}),
|
|
126
|
+
...(args.anchor?.length ? { anchor: args.anchor } : {}),
|
|
127
|
+
...(args.timeline?.length ? { timeline: args.timeline } : {}),
|
|
19
128
|
};
|
|
20
129
|
// Each call uses model tokens, and the server corrects a failing query
|
|
21
130
|
// once, so a failed call is not retried.
|
|
22
|
-
const response = await target
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
131
|
+
const response = (progress ? await streamedRewrite(target, body, progress) : undefined) ??
|
|
132
|
+
(await target.request("POST", "/v1/query/rewrite", {
|
|
133
|
+
body,
|
|
134
|
+
retry: false,
|
|
135
|
+
query: { consistency: target.defaultConsistency },
|
|
136
|
+
}));
|
|
27
137
|
const { route, query, result } = response;
|
|
28
138
|
const traceId = result?.trace_id ?? null;
|
|
29
139
|
const answer = {
|
|
@@ -34,18 +144,23 @@ export async function answerQuestion(client, args, textFormat) {
|
|
|
34
144
|
...(typeof query?.as_of_commit_seq === "number"
|
|
35
145
|
? { as_of_commit_seq: query.as_of_commit_seq }
|
|
36
146
|
: {}),
|
|
37
|
-
...(response.history ? { history: response.history } : {}),
|
|
147
|
+
...(response.history ? { history: historyView(response.history) } : {}),
|
|
38
148
|
error: response.error ?? null,
|
|
39
149
|
trace_id: traceId,
|
|
40
150
|
attempts: response.attempts,
|
|
151
|
+
...(response.linked?.length ? { linked: response.linked } : {}),
|
|
152
|
+
...(response.anchors?.length ? { anchors: response.anchors } : {}),
|
|
41
153
|
};
|
|
42
154
|
const notes = [];
|
|
155
|
+
for (const anchor of response.anchors ?? []) {
|
|
156
|
+
if (anchor.note) {
|
|
157
|
+
notes.push(`anchor ${anchor.iri}: ${anchor.note}.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
43
160
|
if (traceId) {
|
|
44
161
|
notes.push(`eval trace ${traceId}: to label rows, read their item ids with lbb_evals action=trace trace_id=${traceId}, then call lbb_evals action=label trace_id=${traceId} item=<id> valid=true|false (or items=[…]).`);
|
|
45
162
|
}
|
|
46
|
-
|
|
47
|
-
notes.push(`The question names the date ${response.history.as_of_date}. Find the commit of that date, then run the query with lbb_query mode=sparql as_of_commit_seq=<commit>.`);
|
|
48
|
-
}
|
|
163
|
+
notes.push(...historyNotes(response.history));
|
|
49
164
|
if (response.error && query) {
|
|
50
165
|
notes.push("The last query failed (see error). Correct it and run it with lbb_query mode=sparql, or ask again with more context.");
|
|
51
166
|
}
|
package/dist/tool-contracts.js
CHANGED
|
@@ -4,6 +4,12 @@ export const HARD_OUTPUT_CHARS = 80_000;
|
|
|
4
4
|
export const MAX_QUERY_ROW_LIMIT = 5_000;
|
|
5
5
|
/** The most rows `POST /v1/query/rewrite` returns from a run. */
|
|
6
6
|
export const QUESTION_MAX_ROWS = 1_000;
|
|
7
|
+
/** Entity IRIs one question may anchor, and the characters of one. */
|
|
8
|
+
export const QUESTION_MAX_ANCHORS = 10;
|
|
9
|
+
export const QUESTION_MAX_ANCHOR_CHARS = 2_000;
|
|
10
|
+
/** Dated points one question's timeline may carry, and the characters of a label. */
|
|
11
|
+
export const QUESTION_MAX_TIMELINE = 200;
|
|
12
|
+
export const QUESTION_MAX_TIMELINE_LABEL_CHARS = 200;
|
|
7
13
|
export const READ_ONLY = { readOnlyHint: true };
|
|
8
14
|
export const IDEMPOTENT_WRITE = {
|
|
9
15
|
readOnlyHint: false,
|
|
@@ -398,7 +404,7 @@ export const inspectInputSchema = z.discriminatedUnion("action", [
|
|
|
398
404
|
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).';
|
|
399
405
|
// Search by meaning runs inside SPARQL text as a pattern, so an agent can
|
|
400
406
|
// filter and join the hits in one query instead of a search then a lookup.
|
|
401
|
-
export const SPARQL_SEARCH_GUIDE = 'Search by meaning inside the query (the graph needs an embedding; see lbb_embeddings action=list): PREFIX search: <https://littlebigbrain.com/search#> SELECT ?x ?score WHERE { ?x search:similarTo "card payments" ; search:score ?score . ?x <https://littlebigbrain.com/r/calls> ?y } ORDER BY DESC(?score) LIMIT 5. The other patterns filter and join the hits. One search:similarTo per query, with a variable subject, in the main group (not inside OPTIONAL, UNION, MINUS or a subquery). The object is text, an entity IRI (records like that one) or a vector literal. search:top N sets the hits (1 to 1000; default the query LIMIT, then 10) and search:embedding "name" searches one embedding. FILTER(?score > 0.8) keeps close hits. The result\'s search field reports the plan (nearest, filter_first or search_first), the hits and complete.';
|
|
407
|
+
export const SPARQL_SEARCH_GUIDE = 'Search by meaning inside the query (the graph needs an embedding; see lbb_embeddings action=list): PREFIX search: <https://littlebigbrain.com/search#> SELECT ?x ?score WHERE { ?x search:similarTo "card payments" ; search:score ?score . ?x <https://littlebigbrain.com/r/calls> ?y } ORDER BY DESC(?score) LIMIT 5. The other patterns filter and join the hits. One search:similarTo per query, with a variable subject, in the main group (not inside OPTIONAL, UNION, MINUS or a subquery). The object is text, an entity IRI (records like that one) or a vector literal. search:top N sets the hits (1 to 1000; default the query LIMIT, then 10) and search:embedding "name" searches one embedding. FILTER(?score > 0.8) keeps close hits. The result\'s search field reports the plan (nearest, filter_first or search_first), the hits and complete. For a question in words, add search:rerank true ; search:relevance ?r and ORDER BY DESC(?r): the rerank model (Jev) reads the best hits with the text, keeps the top that answer it and binds its answer (0 to 1); search.rerank reports the status. Only a text query reranks.';
|
|
402
408
|
export const queryInputSchema = z.discriminatedUnion("mode", [
|
|
403
409
|
z
|
|
404
410
|
.object({
|
|
@@ -495,6 +501,33 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
|
|
|
495
501
|
.boolean()
|
|
496
502
|
.optional()
|
|
497
503
|
.describe("question: run the query and return its rows. Defaults to true; false returns the query only."),
|
|
504
|
+
anchor: z
|
|
505
|
+
.array(z.string().min(1).max(QUESTION_MAX_ANCHOR_CHARS))
|
|
506
|
+
.max(QUESTION_MAX_ANCHORS)
|
|
507
|
+
.optional()
|
|
508
|
+
.describe("question: entity IRIs the question is about, at most 10 (for example the record the user has open). The server reads each one, and the query uses these IRIs directly instead of matching their names."),
|
|
509
|
+
timeline: z
|
|
510
|
+
.array(z
|
|
511
|
+
.object({
|
|
512
|
+
date: z
|
|
513
|
+
.string()
|
|
514
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
515
|
+
.describe("YYYY-MM-DD."),
|
|
516
|
+
as_of_commit_seq: z
|
|
517
|
+
.number()
|
|
518
|
+
.int()
|
|
519
|
+
.min(0)
|
|
520
|
+
.describe("The commit that holds the graph as it was on that date."),
|
|
521
|
+
label: z
|
|
522
|
+
.string()
|
|
523
|
+
.max(QUESTION_MAX_TIMELINE_LABEL_CHARS)
|
|
524
|
+
.optional()
|
|
525
|
+
.describe("A name for the point, such as a milestone."),
|
|
526
|
+
})
|
|
527
|
+
.strict())
|
|
528
|
+
.max(QUESTION_MAX_TIMELINE)
|
|
529
|
+
.optional()
|
|
530
|
+
.describe("question: dated points that stand for the graph's commits, at most 200. A question about a date reads the commit of the latest point on or before it. Use it when the commits stand for other dates than the days they were written (a demo's milestones, an import of old records); without it the server reads the last commit written by the end of that day."),
|
|
498
531
|
...readScope,
|
|
499
532
|
})
|
|
500
533
|
.strict(),
|
|
@@ -695,6 +728,48 @@ export const configureInputSchema = z.discriminatedUnion("action", [
|
|
|
695
728
|
...graphScope,
|
|
696
729
|
})
|
|
697
730
|
.strict(),
|
|
731
|
+
z
|
|
732
|
+
.object({
|
|
733
|
+
action: z.literal("get_rewrite_profile"),
|
|
734
|
+
...graphScope,
|
|
735
|
+
})
|
|
736
|
+
.strict(),
|
|
737
|
+
z
|
|
738
|
+
.object({
|
|
739
|
+
action: z.literal("set_rewrite_profile"),
|
|
740
|
+
notes: z
|
|
741
|
+
.string()
|
|
742
|
+
.max(8000)
|
|
743
|
+
.optional()
|
|
744
|
+
.describe("What the data means for questions: which property holds the current state, what 'me' or 'my' means, which records to leave out. At most 8,000 characters"),
|
|
745
|
+
examples: z
|
|
746
|
+
.array(z
|
|
747
|
+
.object({
|
|
748
|
+
question: z.string().min(1).max(1000),
|
|
749
|
+
sparql: z
|
|
750
|
+
.string()
|
|
751
|
+
.min(1)
|
|
752
|
+
.max(4000)
|
|
753
|
+
.describe("A SELECT or ASK query with its PREFIX lines; the server parses it"),
|
|
754
|
+
note: z.string().max(1000).optional(),
|
|
755
|
+
})
|
|
756
|
+
.strict())
|
|
757
|
+
.max(20)
|
|
758
|
+
.optional()
|
|
759
|
+
.describe("Up to 20 worked examples: a question and the query that answers it on this graph"),
|
|
760
|
+
expected_version: z
|
|
761
|
+
.number()
|
|
762
|
+
.int()
|
|
763
|
+
.nonnegative()
|
|
764
|
+
.optional()
|
|
765
|
+
.describe("The version get_rewrite_profile returned (0 for none): another stored version answers 409 conflict and stores nothing"),
|
|
766
|
+
dry_run: z
|
|
767
|
+
.boolean()
|
|
768
|
+
.optional()
|
|
769
|
+
.describe("Check the profile and answer what a write would store, without storing it"),
|
|
770
|
+
...graphScope,
|
|
771
|
+
})
|
|
772
|
+
.strict(),
|
|
698
773
|
]);
|
|
699
774
|
/**
|
|
700
775
|
* MCP's `registerTool` advertises a JSON Schema for an input only when the
|
package/dist/tool-runtime.js
CHANGED
|
@@ -660,7 +660,7 @@ export async function guide(scopedClient) {
|
|
|
660
660
|
search_feedback: "When a result set is useful or clearly wrong, write relevance labels with lbb_commit mode=search_feedback. Use grade 3 for ideal/good results, grade 1 for partially relevant results, and grade 0 for bad results. Include the original query, search_id when present, target identity, rank, score, and an optional split train/eval/unspecified. These labels are stored in __lbb_feedback/main and later exported as qrels-style training/eval data; they are not customer facts.",
|
|
661
661
|
inspect: "Use lbb_inspect for ontology, schema metadata, the published conformance report, metadata, entities, and this guide. Use lbb_query with SPARQL property paths for exact path queries.",
|
|
662
662
|
ontology_decorations: "lbb_inspect action=ontology returns a decoration_status catalog: each ontology decoration is enforced (the engine acts on it — state_reducer, value_type, super_types, properties, supernode_policy; cardinality, which GET /v1/ontology/conformance audits as sh:maxCount; and inverse_name/symmetric, which SPARQL resolves as relation aliases — an inverse name is queryable directly (lowered to ^forward, no stored inverse triple) and a symmetric relation matches both directions), advisory (transitive, temporal_semantics, required), or reserved (stored but unwired — default_weight, resolvable, alias/embedding_fields). You can also always reverse any relation in SPARQL by flipping the triple pattern or using ^forward. Each relation_def also carries edge_count — the number of current edges of that relation in this graph's snapshot — so you can tell at a glance which declared relations are actually populated (edge_count 0 = declared but unused) without a separate summary call.",
|
|
663
|
-
search: "Use lbb_query mode=search to find instances by meaning. It covers every searchable class; filter narrows it by class and by relationship, and the graph checks every hit. To plan a search: (1) lbb_embeddings action=list gives the searchable classes, the facts each text is made from, and the graph's model. (2) lbb_query mode=sparql shows the relationships of a class on a sample: PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> SELECT ?p (COUNT(DISTINCT ?s) AS ?n) (SAMPLE(?l) AS ?example) WHERE { { SELECT ?s WHERE { ?s a <CLASS> } LIMIT 500 } ?s ?p ?o . FILTER(isIRI(?o) && ?p != rdf:type) OPTIONAL { ?o rdfs:label ?l } } GROUP BY ?p ORDER BY DESC(?n); write ?o ?p ?s for the links into the class, and SELECT ?c WHERE { ?c rdfs:subClassOf* <CLASS> } (same prefixes) for its subclasses. (3) lbb_query mode=search with filter [{class}, {via, to}] and explain=true returns the plan: the resolved relationship and entities, the allowed count, and the mode, with no model call. Check it, then run the search without explain. A name in to resolves against labels (a small typo too); an unknown relationship or name answers with the options. To search and filter in one query, use SPARQL text: PREFIX search: <https://littlebigbrain.com/search#> SELECT ?x ?score WHERE { ?x search:similarTo \"words\" ; search:score ?score . ?x a <CLASS> } ORDER BY DESC(?score) LIMIT 10. Any other pattern on ?x filters the hits; the result's search field reports the plan and complete. (4) For a question in words, rerank=true lets the rerank model (Jev) put first the hits that answer it; each hit then carries relevance (0 to 1), and the search takes about 0.3 s more. Without rerank the graph's search setting decides.",
|
|
663
|
+
search: "Use lbb_query mode=search to find instances by meaning. It covers every searchable class; filter narrows it by class and by relationship, and the graph checks every hit. To plan a search: (1) lbb_embeddings action=list gives the searchable classes, the facts each text is made from, and the graph's model. (2) lbb_query mode=sparql shows the relationships of a class on a sample: PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> SELECT ?p (COUNT(DISTINCT ?s) AS ?n) (SAMPLE(?l) AS ?example) WHERE { { SELECT ?s WHERE { ?s a <CLASS> } LIMIT 500 } ?s ?p ?o . FILTER(isIRI(?o) && ?p != rdf:type) OPTIONAL { ?o rdfs:label ?l } } GROUP BY ?p ORDER BY DESC(?n); write ?o ?p ?s for the links into the class, and SELECT ?c WHERE { ?c rdfs:subClassOf* <CLASS> } (same prefixes) for its subclasses. (3) lbb_query mode=search with filter [{class}, {via, to}] and explain=true returns the plan: the resolved relationship and entities, the allowed count, and the mode, with no model call. Check it, then run the search without explain. A name in to resolves against labels (a small typo too); an unknown relationship or name answers with the options. To search and filter in one query, use SPARQL text: PREFIX search: <https://littlebigbrain.com/search#> SELECT ?x ?score WHERE { ?x search:similarTo \"words\" ; search:score ?score . ?x a <CLASS> } ORDER BY DESC(?score) LIMIT 10. Any other pattern on ?x filters the hits; the result's search field reports the plan and complete. (4) For a question in words, rerank=true lets the rerank model (Jev) put first the hits that answer it; each hit then carries relevance (0 to 1), and the search takes about 0.3 s more. Without rerank the graph's search setting decides. In SPARQL text, add search:rerank true ; search:relevance ?r to the search triples and ORDER BY DESC(?r); a SPARQL search reranks only then.",
|
|
664
664
|
query: "Use lbb_query for questions in plain words (mode=question), structured SPARQL-subset bodies, SPARQL text, and canned analysis. SPARQL is the only query surface; mode=question also returns the SPARQL query the server wrote for the question. A mode=structured body is { patterns: [{ subject, predicate, object }], filters?, group_by?, group_keys?, aggregates?, having? }; a pattern `predicate` is a relation name and is case-insensitive. mode=structured GROUP BY is not limited to entity identity: group_keys can key on a typed scalar property or calendar bucket. In SPARQL text, relations are <https://littlebigbrain.com/r/NAME> and types <https://littlebigbrain.com/class/NAME>. Each query pins one published watermark.",
|
|
665
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.",
|
|
666
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.",
|
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { metadataPage } from "./metadata-pages.js";
|
|
3
|
-
import { answerQuestion } from "./question.js";
|
|
3
|
+
import { answerQuestion, } from "./question.js";
|
|
4
4
|
import { registerRdfTool } from "./rdf-tool.js";
|
|
5
5
|
import { queryTiming } from "./query-observer.js";
|
|
6
6
|
import { DESTRUCTIVE, IDEMPOTENT_WRITE, MUTATING, READ_ONLY, configureInputSchema, configureWireSchema, detailSchema, graphScope, inspectInputSchema, inspectWireSchema, jsonObjectSchema, queryInputSchema, queryWireSchema, searchFeedbackSchema, yearMonthSchema, } from "./tool-contracts.js";
|
|
@@ -66,7 +66,7 @@ export function registerLbbTools(server, client, options = {}) {
|
|
|
66
66
|
description: "Analytical and expert reads. Modes: question (a question in plain words), structured (SPARQL-subset JSON body), sparql (SPARQL text), search (instances by meaning over every searchable class; filter narrows by class and relationship; every hit checked against the graph), analyze. Use mode=question when you have a question in plain words and no SPARQL query: the server selects the kind of query (route), writes the SPARQL query from a description of the graph, checks it, runs it, and returns the route, the rationale, the query and its rows. Continue or correct that query with mode=sparql. Each question uses model tokens and counts toward a daily limit of the stack. SPARQL is the query language; search finds what the words describe. SPARQL text can also search by meaning inside the query with ?x <https://littlebigbrain.com/search#similarTo> \"words\", so the other patterns filter and join the hits in one query; the result's search field reports the plan. To plan a search: lbb_embeddings action=list, then SPARQL for a class's relationships on a sample, then mode=search with explain=true to check the resolved filter before the real search (lbb_inspect action=guide has the queries). Relations are <https://littlebigbrain.com/r/NAME> and types <https://littlebigbrain.com/class/NAME> (both lowercased); entities are content-addressed, so anchor a named one by its rdfs:label rather than building its IRI. Structured and text queries pin one published watermark for the request.",
|
|
67
67
|
inputSchema: queryWireSchema,
|
|
68
68
|
annotations: READ_ONLY,
|
|
69
|
-
}, (rawArgs) => {
|
|
69
|
+
}, (rawArgs, extra) => {
|
|
70
70
|
const parsed = queryInputSchema.safeParse(rawArgs);
|
|
71
71
|
if (!parsed.success)
|
|
72
72
|
return errorResult(parsed.error);
|
|
@@ -250,6 +250,10 @@ export function registerLbbTools(server, client, options = {}) {
|
|
|
250
250
|
if (search && !search.complete) {
|
|
251
251
|
notes.push(`search bound ${search.hits} of the ${search.top} hits asked for (complete: false): fewer entities satisfy the rest of the query among the candidates the search may score. When at most 20,000 entities match the other patterns, the search scores every match.`);
|
|
252
252
|
}
|
|
253
|
+
const rerank = search?.rerank ?? undefined;
|
|
254
|
+
if (rerank && rerank.status !== "applied") {
|
|
255
|
+
notes.push(`search:rerank did not apply (${rerank.status}${rerank.error ? `: ${rerank.error}` : ""}): the rows hold the most similar hits, and the relevance variable is unbound.`);
|
|
256
|
+
}
|
|
253
257
|
const data = timing.measure("query_results_parse", () => JSON.parse(response.results));
|
|
254
258
|
const rowPage = rowPageFrom(response);
|
|
255
259
|
const cursorBase = {
|
|
@@ -275,9 +279,21 @@ export function registerLbbTools(server, client, options = {}) {
|
|
|
275
279
|
}
|
|
276
280
|
if (args.mode === "question") {
|
|
277
281
|
const questionArgs = args;
|
|
282
|
+
// A call with a progress token gets one notification per step of
|
|
283
|
+
// the server's work; the result is the same.
|
|
284
|
+
const progressToken = extra._meta?.progressToken;
|
|
285
|
+
const progress = progressToken === undefined
|
|
286
|
+
? undefined
|
|
287
|
+
: {
|
|
288
|
+
signal: extra.signal,
|
|
289
|
+
report: (progress, message) => extra.sendNotification({
|
|
290
|
+
method: "notifications/progress",
|
|
291
|
+
params: { progressToken, progress, message },
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
278
294
|
return (async () => {
|
|
279
295
|
try {
|
|
280
|
-
return await answerQuestion(client, questionArgs, options.queryTextFormat);
|
|
296
|
+
return await answerQuestion(client, questionArgs, options.queryTextFormat, progress);
|
|
281
297
|
}
|
|
282
298
|
catch (error) {
|
|
283
299
|
return errorResult(await enrichError(client, error));
|
|
@@ -789,7 +805,7 @@ export function registerLbbTools(server, client, options = {}) {
|
|
|
789
805
|
});
|
|
790
806
|
}));
|
|
791
807
|
server.registerTool("lbb_configure", {
|
|
792
|
-
description: "Manage native schema metadata. Actions: define_ontology (friendly spec with super_types), evolve_ontology (ordered edits including add_super_types), list_starters (the base ontologies crm, documents and work, each with its status on the graph: absent, partial or applied, what applying adds, and conflicts), apply_starter (add what the graph lacks of a starter in one ontology version; a relation the graph has is widened; refused with starter_conflict when the graph holds a term differently; dry_run previews), publish_schema (SHACL activation), suggest_ontology_change (file a change for a person to review instead of applying it; prefer it when the graph's owner reviews ontology changes, and list the result with lbb_inspect action=ontology_suggestions). define, evolve and
|
|
808
|
+
description: "Manage native schema metadata. Actions: define_ontology (friendly spec with super_types), evolve_ontology (ordered edits including add_super_types), list_starters (the base ontologies crm, documents and work, each with its status on the graph: absent, partial or applied, what applying adds, and conflicts), apply_starter (add what the graph lacks of a starter in one ontology version; a relation the graph has is widened; refused with starter_conflict when the graph holds a term differently; dry_run previews), publish_schema (SHACL activation), suggest_ontology_change (file a change for a person to review instead of applying it; prefer it when the graph's owner reviews ontology changes, and list the result with lbb_inspect action=ontology_suggestions), get_rewrite_profile and set_rewrite_profile (the graph's notes and up to 20 worked question-to-SPARQL examples that lbb_query mode=question reads for every question; pass the version you read as expected_version). define, evolve, publish and set_rewrite_profile 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.",
|
|
793
809
|
inputSchema: configureWireSchema,
|
|
794
810
|
annotations: MUTATING,
|
|
795
811
|
}, (rawArgs) => {
|
|
@@ -823,6 +839,18 @@ export function registerLbbTools(server, client, options = {}) {
|
|
|
823
839
|
if (args.action === "list_starters") {
|
|
824
840
|
return scoped(client, args.graph).ontology.starters.list();
|
|
825
841
|
}
|
|
842
|
+
if (args.action === "get_rewrite_profile") {
|
|
843
|
+
return scoped(client, args.graph).query.rewriteProfile();
|
|
844
|
+
}
|
|
845
|
+
if (args.action === "set_rewrite_profile") {
|
|
846
|
+
return scoped(client, args.graph).query.setRewriteProfile({
|
|
847
|
+
notes: args.notes ?? "",
|
|
848
|
+
examples: args.examples ?? [],
|
|
849
|
+
...(args.expected_version !== undefined
|
|
850
|
+
? { expected_version: args.expected_version }
|
|
851
|
+
: {}),
|
|
852
|
+
}, { dryRun: args.dry_run });
|
|
853
|
+
}
|
|
826
854
|
if (args.action === "apply_starter") {
|
|
827
855
|
return scoped(client, args.graph).ontology.starters.apply(args.starter, {
|
|
828
856
|
dryRun: args.dry_run,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@littlebigbrain/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "MCP server for little big brain: search, graph queries, and data import",
|
|
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.
|
|
55
|
+
"@littlebigbrain/client": "^0.20.0",
|
|
56
56
|
"@modelcontextprotocol/sdk": "^1",
|
|
57
57
|
"zod": "^3"
|
|
58
58
|
},
|