@littlebigbrain/mcp 0.15.1 → 0.16.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 +5 -0
- package/dist/approval.d.ts +139 -0
- package/dist/approval.js +426 -0
- package/dist/files-tool.d.ts +2 -2
- package/dist/files-tool.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/query-observer.d.ts +37 -0
- package/dist/rdf-tool.d.ts +2 -2
- package/dist/rdf-tool.js +18 -4
- package/dist/reviews-tool.d.ts +3 -0
- package/dist/reviews-tool.js +420 -0
- package/dist/sparql-update.d.ts +15 -0
- package/dist/sparql-update.js +146 -0
- package/dist/tool-contracts.js +227 -16
- package/dist/tool-runtime.js +91 -5
- package/dist/tools.js +761 -273
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -67,6 +67,11 @@ The client starts the server and communicates through standard input and output
|
|
|
67
67
|
(stdio). Keep the key in your local configuration. Set `LBB_GRAPH` to change
|
|
68
68
|
the default graph from `main`.
|
|
69
69
|
|
|
70
|
+
A call that deletes or removes data asks you to approve it in the client. A
|
|
71
|
+
client that cannot show the question gets the error `approval_required`, and
|
|
72
|
+
nothing is sent. Set `LBB_MCP_DESTRUCTIVE=allow` in the server's environment
|
|
73
|
+
to run such calls without a question.
|
|
74
|
+
|
|
70
75
|
## Run a first query
|
|
71
76
|
|
|
72
77
|
This example stores three facts in a graph named `quickstart`: a service writes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
3
|
+
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import type { LbbClient } from "@littlebigbrain/client";
|
|
5
|
+
import type { LbbServerOptions } from "./query-observer.js";
|
|
6
|
+
import { errorResult } from "./tool-runtime.js";
|
|
7
|
+
/** The `extra` argument of a tool callback. */
|
|
8
|
+
export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
|
9
|
+
type ErrorResult = ReturnType<typeof errorResult>;
|
|
10
|
+
/** The environment setting that lets destructive calls run without a question. */
|
|
11
|
+
export declare const DESTRUCTIVE_SETTING = "LBB_MCP_DESTRUCTIVE";
|
|
12
|
+
/** The approval rule, for the description of a destructive tool. */
|
|
13
|
+
export declare const APPROVAL_TEXT = "The server asks the person to approve it through the client (MCP form elicitation) and sends nothing when they decline (approval_declined). A client that cannot show the question gets approval_required and nothing is sent, unless the MCP server's environment sets LBB_MCP_DESTRUCTIVE=allow.";
|
|
14
|
+
/** What a destructive call asks the person. */
|
|
15
|
+
export interface ApprovalRequest {
|
|
16
|
+
/**
|
|
17
|
+
* The question, naming what the call deletes or removes first. A
|
|
18
|
+
* function runs only when the person is asked, so the call can read the
|
|
19
|
+
* records it names; its error is the tool's error.
|
|
20
|
+
*/
|
|
21
|
+
message: string | (() => Promise<string>);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A name or a value on one line of a question: newlines and other control
|
|
25
|
+
* characters escaped, so a name cannot add lines of its own; at most `max`
|
|
26
|
+
* characters.
|
|
27
|
+
*/
|
|
28
|
+
export declare function oneLine(text: unknown, max?: number): string;
|
|
29
|
+
/**
|
|
30
|
+
* Text quoted under a line of a question: every line indented by four
|
|
31
|
+
* spaces, other control characters escaped, at most `max` characters.
|
|
32
|
+
*/
|
|
33
|
+
export declare function quoteBlock(text: string, max: number): string;
|
|
34
|
+
/** `graph "<name>"`: the graph a call targets, for a person to read. */
|
|
35
|
+
export declare function graphLabel(client: LbbClient, graph?: string): string;
|
|
36
|
+
/** The tool registry of one MCP server: a call log, and the approval step. */
|
|
37
|
+
export interface ToolHost {
|
|
38
|
+
readonly server: McpServer;
|
|
39
|
+
/** `server.registerTool`, with one log event per call. */
|
|
40
|
+
readonly register: McpServer["registerTool"];
|
|
41
|
+
/**
|
|
42
|
+
* Ask the person before a destructive call (principle 13: destructive
|
|
43
|
+
* writes need a person's approval). A client with form elicitation shows
|
|
44
|
+
* the question with one confirm box; a decline, a cancel or an unticked
|
|
45
|
+
* box sends nothing. A client without form elicitation cannot ask, so
|
|
46
|
+
* the call is refused with approval_required, unless the server's
|
|
47
|
+
* environment sets LBB_MCP_DESTRUCTIVE=allow. No tool argument lets a
|
|
48
|
+
* call through: an agent sets the arguments. Returns undefined when the
|
|
49
|
+
* call may go ahead, else the error result to return.
|
|
50
|
+
*/
|
|
51
|
+
approve(extra: ToolExtra, request: ApprovalRequest): Promise<ErrorResult | undefined>;
|
|
52
|
+
}
|
|
53
|
+
/** The host of one server's tools; `options` name the approval setting and the log. */
|
|
54
|
+
export declare function toolHost(server: McpServer, options?: LbbServerOptions): ToolHost;
|
|
55
|
+
/** `1 link`, `2 links`. */
|
|
56
|
+
export declare function counted(n: number, noun: string, plural?: string): string;
|
|
57
|
+
type NamedRecord = {
|
|
58
|
+
type: string;
|
|
59
|
+
name: string;
|
|
60
|
+
};
|
|
61
|
+
/** The retract question: the counts, then every link and record it removes, the first 20 of each. */
|
|
62
|
+
export declare function retractMessage(where: string, edges: {
|
|
63
|
+
source: NamedRecord;
|
|
64
|
+
relation: string;
|
|
65
|
+
target: NamedRecord;
|
|
66
|
+
}[], entities: NamedRecord[]): string;
|
|
67
|
+
/**
|
|
68
|
+
* An `evolve_ontology` operation, as the tool or a suggestion holds it.
|
|
69
|
+
* Only the fields a removal names are read.
|
|
70
|
+
*/
|
|
71
|
+
export type OntologyOpLike = {
|
|
72
|
+
op: string;
|
|
73
|
+
name?: unknown;
|
|
74
|
+
relation?: unknown;
|
|
75
|
+
remove_domain?: unknown;
|
|
76
|
+
remove_range?: unknown;
|
|
77
|
+
};
|
|
78
|
+
/** Whether an operation removes: a type, a relation, or a relation's source or target types. */
|
|
79
|
+
export declare function removesFromOntology(op: OntologyOpLike): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* The question of an `evolve_ontology` call that removes: the counts, then
|
|
82
|
+
* what `remove_entity_type`, `remove_relation` and the `remove_domain` and
|
|
83
|
+
* `remove_range` of `narrow_relation` remove, the first 20. Undefined when
|
|
84
|
+
* the call removes nothing.
|
|
85
|
+
*/
|
|
86
|
+
export declare function ontologyRemovalMessage(where: string, ops: OntologyOpLike[]): string | undefined;
|
|
87
|
+
/** The facts a review item commits, as far as a question names them. */
|
|
88
|
+
export type FactsLike = {
|
|
89
|
+
triplets?: unknown[] | null;
|
|
90
|
+
entity_properties?: unknown[] | null;
|
|
91
|
+
replace_owned?: {
|
|
92
|
+
subjects?: unknown[] | null;
|
|
93
|
+
retract_entities?: unknown[] | null;
|
|
94
|
+
} | null;
|
|
95
|
+
};
|
|
96
|
+
/** `2 links and 1 field row; …`: what committing `facts` writes and removes. */
|
|
97
|
+
export declare function factsSummary(facts: FactsLike): string | undefined;
|
|
98
|
+
/** A review item an answer would commit or apply. */
|
|
99
|
+
export interface ReviewTarget {
|
|
100
|
+
id: string;
|
|
101
|
+
title: string;
|
|
102
|
+
kind: string;
|
|
103
|
+
question?: {
|
|
104
|
+
property?: string | null;
|
|
105
|
+
value?: unknown;
|
|
106
|
+
unit?: string | null;
|
|
107
|
+
} | null;
|
|
108
|
+
subject?: {
|
|
109
|
+
name?: string | null;
|
|
110
|
+
} | null;
|
|
111
|
+
facts?: FactsLike | null;
|
|
112
|
+
}
|
|
113
|
+
/** A change item's suggestion: its operations and facts, or undefined when it could not be read. */
|
|
114
|
+
export type ChangeDetail = {
|
|
115
|
+
ops: OntologyOpLike[];
|
|
116
|
+
facts?: FactsLike | null;
|
|
117
|
+
} | undefined;
|
|
118
|
+
/**
|
|
119
|
+
* The question of a review answer that commits or applies: the count and
|
|
120
|
+
* the effect first (how many value items commit a value, how many change
|
|
121
|
+
* items apply an ontology change, and what those changes remove), then
|
|
122
|
+
* each item, the first 20. `unchanged` counts the other answered items.
|
|
123
|
+
*/
|
|
124
|
+
export declare function reviewAnswerMessage(where: string, answer: {
|
|
125
|
+
decision: "accept" | "correct";
|
|
126
|
+
value?: unknown;
|
|
127
|
+
}, items: ReviewTarget[], changes: Map<string, ChangeDetail>, unchanged: number): string;
|
|
128
|
+
/** The question of a shape publish in reject mode. */
|
|
129
|
+
export declare function shapesRejectMessage(where: string, shapes: {
|
|
130
|
+
source: string;
|
|
131
|
+
format?: string;
|
|
132
|
+
}): string;
|
|
133
|
+
/**
|
|
134
|
+
* The question of a SPARQL Update that can remove statements: the
|
|
135
|
+
* operations that remove come first (DELETE, CLEAR, DROP, MOVE, COPY),
|
|
136
|
+
* each quoted on lines of its own, then the forms of the other operations.
|
|
137
|
+
*/
|
|
138
|
+
export declare function sparqlUpdateMessage(where: string, update: string): string;
|
|
139
|
+
export {};
|
package/dist/approval.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { updateOperations } from "./sparql-update.js";
|
|
2
|
+
import { ToolError, errorResult } from "./tool-runtime.js";
|
|
3
|
+
/** How long the person has to answer an approval request. */
|
|
4
|
+
const APPROVAL_TIMEOUT_MS = 10 * 60_000;
|
|
5
|
+
/** The environment setting that lets destructive calls run without a question. */
|
|
6
|
+
export const DESTRUCTIVE_SETTING = "LBB_MCP_DESTRUCTIVE";
|
|
7
|
+
/** The approval rule, for the description of a destructive tool. */
|
|
8
|
+
export const APPROVAL_TEXT = `The server asks the person to approve it through the client (MCP form elicitation) and sends nothing when they decline (approval_declined). A client that cannot show the question gets approval_required and nothing is sent, unless the MCP server's environment sets ${DESTRUCTIVE_SETTING}=allow.`;
|
|
9
|
+
/** Control characters, line and paragraph separators, and bidirectional controls. */
|
|
10
|
+
const UNSAFE =
|
|
11
|
+
// eslint-disable-next-line no-control-regex -- these are the characters a name must not carry into a question
|
|
12
|
+
/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
13
|
+
function escapeChar(c) {
|
|
14
|
+
switch (c) {
|
|
15
|
+
case "\n":
|
|
16
|
+
return "\\n";
|
|
17
|
+
case "\r":
|
|
18
|
+
return "\\r";
|
|
19
|
+
case "\t":
|
|
20
|
+
return "\\t";
|
|
21
|
+
default:
|
|
22
|
+
return `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A name or a value on one line of a question: newlines and other control
|
|
27
|
+
* characters escaped, so a name cannot add lines of its own; at most `max`
|
|
28
|
+
* characters.
|
|
29
|
+
*/
|
|
30
|
+
export function oneLine(text, max = 200) {
|
|
31
|
+
const escaped = String(text).replace(UNSAFE, escapeChar);
|
|
32
|
+
return escaped.length > max ? `${escaped.slice(0, max)} …` : escaped;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Text quoted under a line of a question: every line indented by four
|
|
36
|
+
* spaces, other control characters escaped, at most `max` characters.
|
|
37
|
+
*/
|
|
38
|
+
export function quoteBlock(text, max) {
|
|
39
|
+
const normal = text.replace(/\r\n?/g, "\n").replace(/\t/g, " ");
|
|
40
|
+
const shown = normal.length > max ? normal.slice(0, max) : normal;
|
|
41
|
+
const body = shown
|
|
42
|
+
.split("\n")
|
|
43
|
+
.map((line) => ` ${line.replace(UNSAFE, escapeChar)}`)
|
|
44
|
+
.join("\n");
|
|
45
|
+
return normal.length > max
|
|
46
|
+
? `${body}\n … ${(normal.length - max).toLocaleString("en-US")} more characters`
|
|
47
|
+
: body;
|
|
48
|
+
}
|
|
49
|
+
/** `graph "<name>"`: the graph a call targets, for a person to read. */
|
|
50
|
+
export function graphLabel(client, graph) {
|
|
51
|
+
const name = graph ?? client.graphName;
|
|
52
|
+
return name ? `graph "${oneLine(name)}"` : "the connection's graph";
|
|
53
|
+
}
|
|
54
|
+
/** A word of the log: an action or an error code, never free text. */
|
|
55
|
+
function token(value, pattern) {
|
|
56
|
+
return typeof value === "string" && pattern.test(value) ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
function defaultLog(event) {
|
|
59
|
+
process.stderr.write(`${JSON.stringify(event)}\n`);
|
|
60
|
+
}
|
|
61
|
+
/** The host of one server's tools; `options` name the approval setting and the log. */
|
|
62
|
+
export function toolHost(server, options = {}) {
|
|
63
|
+
const setting = options.destructiveWithoutApproval ??
|
|
64
|
+
(process.env[DESTRUCTIVE_SETTING]?.trim().toLowerCase() === "allow"
|
|
65
|
+
? "allow"
|
|
66
|
+
: "refuse");
|
|
67
|
+
const log = options.log ?? defaultLog;
|
|
68
|
+
const calls = new WeakMap();
|
|
69
|
+
const registerTool = server.registerTool.bind(server);
|
|
70
|
+
const register = ((name, config, callback) => registerTool(name, config, async (...params) => {
|
|
71
|
+
const extra = params[params.length - 1];
|
|
72
|
+
const record = {};
|
|
73
|
+
calls.set(extra, record);
|
|
74
|
+
const args = (params.length > 1 ? params[0] : undefined);
|
|
75
|
+
const action = token(args?.action ?? args?.mode, /^[a-z_]{1,40}$/);
|
|
76
|
+
const emit = (outcome, code) => {
|
|
77
|
+
try {
|
|
78
|
+
log({
|
|
79
|
+
event: "mcp_tool_call",
|
|
80
|
+
tool: name,
|
|
81
|
+
...(action ? { action } : {}),
|
|
82
|
+
outcome,
|
|
83
|
+
...(code ? { code } : {}),
|
|
84
|
+
...(record.approval ? { approval: record.approval } : {}),
|
|
85
|
+
request_id: extra.requestId,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// A log that fails never fails the call.
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
const result = (await callback(...params));
|
|
94
|
+
emit(result?.isError ? "error" : "ok", result?.isError
|
|
95
|
+
? token(result.structuredContent?.error?.code, /^[a-z0-9_.]{1,64}$/)
|
|
96
|
+
: undefined);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
emit("error", "exception");
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
const approve = async (extra, request) => {
|
|
105
|
+
const record = calls.get(extra);
|
|
106
|
+
const decide = (decision) => {
|
|
107
|
+
if (record)
|
|
108
|
+
record.approval = decision;
|
|
109
|
+
};
|
|
110
|
+
if (!server.server.getClientCapabilities()?.elicitation?.form) {
|
|
111
|
+
if (setting === "allow") {
|
|
112
|
+
decide("allowed_by_setting");
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
decide("unavailable");
|
|
116
|
+
return errorResult(new ToolError("approval_required", `This call deletes or removes data, and this client cannot ask the person to approve it (it has no MCP form elicitation), so nothing was sent. Make this change in the console or with the TypeScript or Python SDK, or from an MCP client that can show the question. On an MCP server you run yourself, the person can set ${DESTRUCTIVE_SETTING}=allow in its environment; the hosted endpoint never allows it.`));
|
|
117
|
+
}
|
|
118
|
+
let message;
|
|
119
|
+
try {
|
|
120
|
+
message =
|
|
121
|
+
typeof request.message === "string"
|
|
122
|
+
? request.message
|
|
123
|
+
: await request.message();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
return errorResult(error);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const answer = await server.server.elicitInput({
|
|
130
|
+
mode: "form",
|
|
131
|
+
message,
|
|
132
|
+
requestedSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
confirm: {
|
|
136
|
+
type: "boolean",
|
|
137
|
+
title: "Approve",
|
|
138
|
+
description: "Tick to run this call. It cannot be undone.",
|
|
139
|
+
default: false,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
required: ["confirm"],
|
|
143
|
+
},
|
|
144
|
+
}, {
|
|
145
|
+
relatedRequestId: extra.requestId,
|
|
146
|
+
signal: extra.signal,
|
|
147
|
+
timeout: APPROVAL_TIMEOUT_MS,
|
|
148
|
+
});
|
|
149
|
+
if (answer.action === "accept" && answer.content?.confirm === true) {
|
|
150
|
+
decide("approved");
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
decide(answer.action === "cancel" ? "cancelled" : "declined");
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
decide("failed");
|
|
157
|
+
return errorResult(new ToolError("approval_failed", `The approval request failed, so nothing was sent: ${error instanceof Error ? error.message : String(error)}`));
|
|
158
|
+
}
|
|
159
|
+
return errorResult(new ToolError("approval_declined", "The person declined, so nothing was sent."));
|
|
160
|
+
};
|
|
161
|
+
return { server, register, approve };
|
|
162
|
+
}
|
|
163
|
+
/** `1 link`, `2 links`. */
|
|
164
|
+
export function counted(n, noun, plural = `${noun}s`) {
|
|
165
|
+
return `${n.toLocaleString("en-US")} ${n === 1 ? noun : plural}`;
|
|
166
|
+
}
|
|
167
|
+
/** `a`, `a and b`, `a, b and c`. */
|
|
168
|
+
function joined(parts) {
|
|
169
|
+
return parts.length <= 1
|
|
170
|
+
? (parts[0] ?? "")
|
|
171
|
+
: `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
172
|
+
}
|
|
173
|
+
/** The first 20 lines, then a count of the rest. */
|
|
174
|
+
function listed(lines, noun) {
|
|
175
|
+
return lines.length > 20
|
|
176
|
+
? [...lines.slice(0, 20), `- and ${lines.length - 20} more ${noun}`]
|
|
177
|
+
: lines;
|
|
178
|
+
}
|
|
179
|
+
/** The retract question: the counts, then every link and record it removes, the first 20 of each. */
|
|
180
|
+
export function retractMessage(where, edges, entities) {
|
|
181
|
+
const record = (r) => `${oneLine(r.name)} (${oneLine(r.type)})`;
|
|
182
|
+
const what = joined([
|
|
183
|
+
...(edges.length ? [counted(edges.length, "link")] : []),
|
|
184
|
+
...(entities.length
|
|
185
|
+
? [`every current link of ${counted(entities.length, "record")}`]
|
|
186
|
+
: []),
|
|
187
|
+
]);
|
|
188
|
+
return [
|
|
189
|
+
`Retract ${what} from ${where}?`,
|
|
190
|
+
...listed(edges.map((edge) => `- the link ${record(edge.source)} ${oneLine(edge.relation)} ${record(edge.target)}`), "links"),
|
|
191
|
+
...listed(entities.map((entity) => `- every current link of ${record(entity)}`), "records"),
|
|
192
|
+
"The records and their history stay readable in as_of reads.",
|
|
193
|
+
].join("\n");
|
|
194
|
+
}
|
|
195
|
+
function names(value) {
|
|
196
|
+
return Array.isArray(value) ? value.map((name) => oneLine(name)) : [];
|
|
197
|
+
}
|
|
198
|
+
/** What one operation removes, one line each. */
|
|
199
|
+
function removals(op) {
|
|
200
|
+
switch (op.op) {
|
|
201
|
+
case "remove_entity_type":
|
|
202
|
+
return [`- the entity type ${oneLine(op.name)}`];
|
|
203
|
+
case "remove_relation":
|
|
204
|
+
return [`- the relation ${oneLine(op.name)}`];
|
|
205
|
+
case "narrow_relation": {
|
|
206
|
+
const domain = names(op.remove_domain);
|
|
207
|
+
const range = names(op.remove_range);
|
|
208
|
+
return [
|
|
209
|
+
...(domain.length
|
|
210
|
+
? [
|
|
211
|
+
`- ${domain.join(", ")} from the source types (domain) of the relation ${oneLine(op.relation)}`,
|
|
212
|
+
]
|
|
213
|
+
: []),
|
|
214
|
+
...(range.length
|
|
215
|
+
? [
|
|
216
|
+
`- ${range.join(", ")} from the target types (range) of the relation ${oneLine(op.relation)}`,
|
|
217
|
+
]
|
|
218
|
+
: []),
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
default:
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Whether an operation removes: a type, a relation, or a relation's source or target types. */
|
|
226
|
+
export function removesFromOntology(op) {
|
|
227
|
+
return removals(op).length > 0;
|
|
228
|
+
}
|
|
229
|
+
/** `1 entity type, 1 relation and 3 source or target types`. */
|
|
230
|
+
function removalCounts(ops) {
|
|
231
|
+
const of = (kind) => ops.filter((op) => op.op === kind).length;
|
|
232
|
+
const types = ops.reduce((sum, op) => op.op === "narrow_relation"
|
|
233
|
+
? sum + names(op.remove_domain).length + names(op.remove_range).length
|
|
234
|
+
: sum, 0);
|
|
235
|
+
return joined([
|
|
236
|
+
...(of("remove_entity_type")
|
|
237
|
+
? [counted(of("remove_entity_type"), "entity type")]
|
|
238
|
+
: []),
|
|
239
|
+
...(of("remove_relation")
|
|
240
|
+
? [counted(of("remove_relation"), "relation")]
|
|
241
|
+
: []),
|
|
242
|
+
...(types
|
|
243
|
+
? [
|
|
244
|
+
counted(types, "source or target type of a relation", "source or target types of relations"),
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The question of an `evolve_ontology` call that removes: the counts, then
|
|
251
|
+
* what `remove_entity_type`, `remove_relation` and the `remove_domain` and
|
|
252
|
+
* `remove_range` of `narrow_relation` remove, the first 20. Undefined when
|
|
253
|
+
* the call removes nothing.
|
|
254
|
+
*/
|
|
255
|
+
export function ontologyRemovalMessage(where, ops) {
|
|
256
|
+
const removing = ops.filter(removesFromOntology);
|
|
257
|
+
if (!removing.length)
|
|
258
|
+
return undefined;
|
|
259
|
+
const others = ops.length - removing.length;
|
|
260
|
+
const tombstones = removing.some((op) => op.op === "remove_entity_type" || op.op === "remove_relation");
|
|
261
|
+
return [
|
|
262
|
+
`Change the ontology of ${where}? The change removes ${removalCounts(removing)}:`,
|
|
263
|
+
...listed(removing.flatMap(removals), "removals"),
|
|
264
|
+
...(others
|
|
265
|
+
? [`It also applies ${counted(others, "other operation")}.`]
|
|
266
|
+
: []),
|
|
267
|
+
...(tombstones
|
|
268
|
+
? [
|
|
269
|
+
"A removed type or relation stays readable in old records. New commits that use it fail.",
|
|
270
|
+
]
|
|
271
|
+
: []),
|
|
272
|
+
].join("\n");
|
|
273
|
+
}
|
|
274
|
+
/** `2 links and 1 field row; …`: what committing `facts` writes and removes. */
|
|
275
|
+
export function factsSummary(facts) {
|
|
276
|
+
const writes = joined([
|
|
277
|
+
...(facts.triplets?.length ? [counted(facts.triplets.length, "link")] : []),
|
|
278
|
+
...(facts.entity_properties?.length
|
|
279
|
+
? [counted(facts.entity_properties.length, "field row")]
|
|
280
|
+
: []),
|
|
281
|
+
]);
|
|
282
|
+
const owned = facts.replace_owned;
|
|
283
|
+
const parts = [
|
|
284
|
+
...(writes ? [`commits ${writes}`] : []),
|
|
285
|
+
...(owned?.subjects?.length
|
|
286
|
+
? [
|
|
287
|
+
`removes the owned links and fields it does not write again on ${counted(owned.subjects.length, "record")}`,
|
|
288
|
+
]
|
|
289
|
+
: []),
|
|
290
|
+
...(owned?.retract_entities?.length
|
|
291
|
+
? [`retracts ${counted(owned.retract_entities.length, "record")}`]
|
|
292
|
+
: []),
|
|
293
|
+
];
|
|
294
|
+
return parts.length ? parts.join("; ") : undefined;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The question of a review answer that commits or applies: the count and
|
|
298
|
+
* the effect first (how many value items commit a value, how many change
|
|
299
|
+
* items apply an ontology change, and what those changes remove), then
|
|
300
|
+
* each item, the first 20. `unchanged` counts the other answered items.
|
|
301
|
+
*/
|
|
302
|
+
export function reviewAnswerMessage(where, answer, items, changes, unchanged) {
|
|
303
|
+
const values = items.filter((item) => item.kind === "value");
|
|
304
|
+
const changeItems = items.filter((item) => item.kind === "change");
|
|
305
|
+
const total = items.length + unchanged;
|
|
306
|
+
const correct = answer.decision === "correct";
|
|
307
|
+
const effects = [
|
|
308
|
+
...(values.length
|
|
309
|
+
? [
|
|
310
|
+
values.length === 1
|
|
311
|
+
? `1 value item commits ${correct ? "the corrected value" : "its value"}`
|
|
312
|
+
: `${counted(values.length, "value item")} commit ${correct ? "the corrected value" : "their values"}`,
|
|
313
|
+
]
|
|
314
|
+
: []),
|
|
315
|
+
...(changeItems.length
|
|
316
|
+
? [
|
|
317
|
+
changeItems.length === 1
|
|
318
|
+
? "1 change item applies its ontology change"
|
|
319
|
+
: `${counted(changeItems.length, "change item")} apply their ontology changes`,
|
|
320
|
+
]
|
|
321
|
+
: []),
|
|
322
|
+
];
|
|
323
|
+
const head = `${correct ? "Correct" : "Accept"} ${counted(total, "review item")} on ${where}${correct ? ` to ${oneLine(JSON.stringify(answer.value ?? null))}` : ""}? ${joined(effects)}.`;
|
|
324
|
+
const lines = [];
|
|
325
|
+
for (const item of items.slice(0, 20)) {
|
|
326
|
+
const label = `- "${oneLine(item.title)}" (${oneLine(item.id)})`;
|
|
327
|
+
const facts = item.facts ? factsSummary(item.facts) : undefined;
|
|
328
|
+
if (item.kind === "value") {
|
|
329
|
+
const property = item.question?.property;
|
|
330
|
+
const value = correct ? answer.value : item.question?.value;
|
|
331
|
+
const unit = !correct && item.question?.unit ? ` ${item.question.unit}` : "";
|
|
332
|
+
const subject = item.subject?.name
|
|
333
|
+
? ` on ${oneLine(item.subject.name)}`
|
|
334
|
+
: "";
|
|
335
|
+
const what = property
|
|
336
|
+
? `${oneLine(property)} = ${oneLine(JSON.stringify(value ?? null))}${oneLine(unit)}${subject}`
|
|
337
|
+
: "its value";
|
|
338
|
+
lines.push(`${label}: ${what}${facts ? `; ${facts}` : ""}`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const detail = changes.get(item.id);
|
|
342
|
+
if (!detail) {
|
|
343
|
+
lines.push(`${label}: its ontology change and facts could not be read here`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const removing = detail.ops.flatMap(removals);
|
|
347
|
+
const changeFacts = detail.facts ? factsSummary(detail.facts) : undefined;
|
|
348
|
+
lines.push(`${label}: ${counted(detail.ops.length, "ontology operation")}${changeFacts ? `; ${changeFacts}` : ""}${removing.length ? "; the change removes:" : ""}`);
|
|
349
|
+
lines.push(...listed(removing, "removals").map((line) => ` ${line}`));
|
|
350
|
+
}
|
|
351
|
+
if (items.length > 20)
|
|
352
|
+
lines.push(`- and ${counted(items.length - 20, "more item")}`);
|
|
353
|
+
return [
|
|
354
|
+
head,
|
|
355
|
+
...lines,
|
|
356
|
+
...(unchanged
|
|
357
|
+
? [
|
|
358
|
+
unchanged === 1
|
|
359
|
+
? "1 other item changes nothing in the graph."
|
|
360
|
+
: `${counted(unchanged, "other item")} change nothing in the graph.`,
|
|
361
|
+
]
|
|
362
|
+
: []),
|
|
363
|
+
].join("\n");
|
|
364
|
+
}
|
|
365
|
+
/** The target classes a SHACL source names, as written. */
|
|
366
|
+
function targetClasses(source) {
|
|
367
|
+
const found = source.matchAll(/(?:\bsh:targetClass|<http:\/\/www\.w3\.org\/ns\/shacl#targetClass>)\s+(<[^>\s]*>|[A-Za-z][\w.-]*:[\w.-]*|:[\w.-]*)/g);
|
|
368
|
+
return [...new Set([...found].map((match) => oneLine(match[1])))];
|
|
369
|
+
}
|
|
370
|
+
/** The question of a shape publish in reject mode. */
|
|
371
|
+
export function shapesRejectMessage(where, shapes) {
|
|
372
|
+
const targets = targetClasses(shapes.source);
|
|
373
|
+
return [
|
|
374
|
+
`Publish these SHACL rules on ${where} in reject mode? From then on, a write that breaks a rule fails, and the error lists the violations.`,
|
|
375
|
+
...(targets.length
|
|
376
|
+
? [
|
|
377
|
+
`The rules name ${counted(targets.length, "target class", "target classes")}:`,
|
|
378
|
+
...listed(targets.map((target) => `- ${target}`), "target classes"),
|
|
379
|
+
]
|
|
380
|
+
: ["No target class was read from the rules."]),
|
|
381
|
+
`The rules are ${counted(shapes.source.length, "character")} of ${oneLine(shapes.format ?? "auto")} text.`,
|
|
382
|
+
].join("\n");
|
|
383
|
+
}
|
|
384
|
+
/** The most characters of one removing operation a question quotes. */
|
|
385
|
+
const OPERATION_CHARS = 1_500;
|
|
386
|
+
/** The most characters of removing operations one question quotes. */
|
|
387
|
+
const UPDATE_CHARS = 6_000;
|
|
388
|
+
/**
|
|
389
|
+
* The question of a SPARQL Update that can remove statements: the
|
|
390
|
+
* operations that remove come first (DELETE, CLEAR, DROP, MOVE, COPY),
|
|
391
|
+
* each quoted on lines of its own, then the forms of the other operations.
|
|
392
|
+
*/
|
|
393
|
+
export function sparqlUpdateMessage(where, update) {
|
|
394
|
+
const operations = updateOperations(update);
|
|
395
|
+
const removing = operations
|
|
396
|
+
.map((operation, i) => ({ operation, n: i + 1 }))
|
|
397
|
+
.filter(({ operation }) => operation.removes);
|
|
398
|
+
if (!removing.length) {
|
|
399
|
+
return [
|
|
400
|
+
`Run this SPARQL Update on ${where}? Its text names DELETE, CLEAR, DROP, MOVE or COPY, so it may remove statements:`,
|
|
401
|
+
quoteBlock(update, 2_000),
|
|
402
|
+
].join("\n");
|
|
403
|
+
}
|
|
404
|
+
const lines = [
|
|
405
|
+
`Run this SPARQL Update on ${where}? ${operations.length === 1
|
|
406
|
+
? "It removes statements:"
|
|
407
|
+
: removing.length === operations.length
|
|
408
|
+
? `All ${operations.length} of its operations remove statements:`
|
|
409
|
+
: `${removing.length} of its ${operations.length} operations remove statements:`}`,
|
|
410
|
+
];
|
|
411
|
+
let budget = UPDATE_CHARS;
|
|
412
|
+
for (const { operation, n } of removing.slice(0, 20)) {
|
|
413
|
+
const shown = Math.max(200, Math.min(OPERATION_CHARS, budget));
|
|
414
|
+
lines.push(`- operation ${n}, ${oneLine(operation.form)}:`);
|
|
415
|
+
lines.push(quoteBlock(operation.text, shown));
|
|
416
|
+
budget -= Math.min(operation.text.length, shown);
|
|
417
|
+
}
|
|
418
|
+
if (removing.length > 20)
|
|
419
|
+
lines.push(`- and ${counted(removing.length - 20, "more operation")} that remove`);
|
|
420
|
+
const others = operations.filter((operation) => !operation.removes);
|
|
421
|
+
if (others.length)
|
|
422
|
+
lines.push(`The other ${others.length === 1 ? "operation" : `${others.length} operations`}: ${[
|
|
423
|
+
...new Set(others.map((operation) => oneLine(operation.form))),
|
|
424
|
+
].join(", ")}.`);
|
|
425
|
+
return lines.join("\n");
|
|
426
|
+
}
|
package/dist/files-tool.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ToolHost } from "./approval.js";
|
|
2
2
|
import type { LbbClient, Schemas } from "@littlebigbrain/client";
|
|
3
3
|
/** Files one `list` call returns: one page, newest upload first. */
|
|
4
4
|
export declare const FILES_PER_LIST = 50;
|
|
@@ -48,5 +48,5 @@ export interface PageLinesAnswer {
|
|
|
48
48
|
* `next` names the page and the offset of the first line left out.
|
|
49
49
|
*/
|
|
50
50
|
export declare function pageLines(pages: FilePage[], window: PageLinesWindow): PageLinesAnswer;
|
|
51
|
-
export declare function registerFilesTool(
|
|
51
|
+
export declare function registerFilesTool(host: ToolHost, client: LbbClient): void;
|
|
52
52
|
export {};
|
package/dist/files-tool.js
CHANGED
|
@@ -112,8 +112,8 @@ async function fileSha(target, args) {
|
|
|
112
112
|
const names = files.slice(0, 20).map((file) => file.name);
|
|
113
113
|
throw new Error(`no file is named ${args.name}${names.length ? `; the graph has ${names.join(", ")}` : "; the graph has no files"}`);
|
|
114
114
|
}
|
|
115
|
-
export function registerFilesTool(
|
|
116
|
-
|
|
115
|
+
export function registerFilesTool(host, client) {
|
|
116
|
+
host.register("lbb_files", {
|
|
117
117
|
description: "Files of the graph, read only. A file is a document the graph holds (its bytes and a Document entity); the documents.parse workflow (lbb_workflows) reads the text layer of each PDF into pages with every line and its box. list shows 50 files a call, newest upload first, with their sha256, name, size, Document IRI and parse (status parsed, unsupported or failed, page_count); when more follow, next names the after of the next call (with the same filters). parse_status keeps the files of one parse status; waiting is a file not parsed yet. name keeps the files whose name holds a text, in any case. A filtered list reads the Documents of the published generation and names its commit (as_of_commit_seq). A file uploaded, renamed or parsed a moment ago can be missing from it until publication. pages reads parsed pages of one file (sha256, or name for an exact file name): from is the first 0-based page index (the page of a region and of a DocEntry or DocMention), to the page after the last one (default from+1). Each line comes as `<line id> [x0,y0,x1,y1] <text>`: the id is p<page number>-l<line number>, both from 1, and the box is normalized to the page (0 to 1, origin top left). contains keeps the lines that hold a text; max_lines caps the lines (default 200). When lines were left out, next names the from and line_offset of the next call. A file not parsed yet answers file_not_parsed.",
|
|
118
118
|
inputSchema: {
|
|
119
119
|
action: z.enum(["list", "pages"]),
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { registerLbbTools } from "./tools.js";
|
|
2
2
|
export { buildLbbServer } from "./server.js";
|
|
3
|
-
export type { LbbServerOptions, McpQueryStage, McpQueryStageEvent, } from "./query-observer.js";
|
|
3
|
+
export type { LbbServerOptions, McpApprovalDecision, McpQueryStage, McpQueryStageEvent, McpToolCallEvent, } from "./query-observer.js";
|
|
4
4
|
export { createMcpHttpServer, type McpHttpServerOptions, } from "./http-server.js";
|
package/dist/query-observer.d.ts
CHANGED
|
@@ -13,7 +13,44 @@ export interface McpQueryStageEvent {
|
|
|
13
13
|
cursor_bytes?: number;
|
|
14
14
|
text_bytes?: number;
|
|
15
15
|
}
|
|
16
|
+
/** What the approval step of a destructive call decided. */
|
|
17
|
+
export type McpApprovalDecision =
|
|
18
|
+
/** The person ticked the box. */
|
|
19
|
+
"approved"
|
|
20
|
+
/** The person declined, or left the box unticked. */
|
|
21
|
+
| "declined" | "cancelled"
|
|
22
|
+
/** The approval request itself failed. */
|
|
23
|
+
| "failed"
|
|
24
|
+
/** The client cannot show the question, so the call was refused. */
|
|
25
|
+
| "unavailable"
|
|
26
|
+
/** The client cannot show the question; LBB_MCP_DESTRUCTIVE=allow let it run. */
|
|
27
|
+
| "allowed_by_setting";
|
|
28
|
+
/**
|
|
29
|
+
* One tool call, for the server's log. It holds no arguments, texts or
|
|
30
|
+
* keys: the tool, its action, the outcome and the JSON-RPC request id.
|
|
31
|
+
*/
|
|
32
|
+
export interface McpToolCallEvent {
|
|
33
|
+
event: "mcp_tool_call";
|
|
34
|
+
tool: string;
|
|
35
|
+
/** The call's `action` (or `mode`), when it names one. */
|
|
36
|
+
action?: string;
|
|
37
|
+
outcome: "ok" | "error";
|
|
38
|
+
/** The error code of a failed call, for example approval_declined. */
|
|
39
|
+
code?: string;
|
|
40
|
+
/** What the approval step decided, when the call is destructive. */
|
|
41
|
+
approval?: McpApprovalDecision;
|
|
42
|
+
request_id: string | number;
|
|
43
|
+
}
|
|
16
44
|
export interface LbbServerOptions {
|
|
45
|
+
/**
|
|
46
|
+
* A destructive call when the client cannot ask the person (no MCP form
|
|
47
|
+
* elicitation): "refuse" returns approval_required and sends nothing;
|
|
48
|
+
* "allow" sends it without a question. Default: "allow" when the
|
|
49
|
+
* environment sets LBB_MCP_DESTRUCTIVE=allow, else "refuse".
|
|
50
|
+
*/
|
|
51
|
+
destructiveWithoutApproval?: "refuse" | "allow";
|
|
52
|
+
/** Receives one event per tool call. Default: a JSON line on stderr. */
|
|
53
|
+
log?: (event: Readonly<McpToolCallEvent>) => void;
|
|
17
54
|
/** Query text only; fitting and rendering always use this same format. */
|
|
18
55
|
queryTextFormat?: "compact" | "pretty";
|
|
19
56
|
timing?: {
|
package/dist/rdf-tool.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
1
|
import type { LbbClient } from "@littlebigbrain/client";
|
|
3
|
-
|
|
2
|
+
import { type ToolHost } from "./approval.js";
|
|
3
|
+
export declare function registerRdfTool(host: ToolHost, client: LbbClient): void;
|