@littlebigbrain/mcp 0.15.1 → 0.17.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.
@@ -1,4 +1,4 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
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;
@@ -33,6 +33,8 @@ export interface PageLinesAnswer {
33
33
  height: number;
34
34
  lines_total: number;
35
35
  lines_matching: number;
36
+ /** The page is mostly image and has fewer than 16 characters of text: the parse read almost none, and OCR is not built. */
37
+ ocr_required?: true;
36
38
  lines: string[];
37
39
  }>;
38
40
  shown: number;
@@ -48,5 +50,5 @@ export interface PageLinesAnswer {
48
50
  * `next` names the page and the offset of the first line left out.
49
51
  */
50
52
  export declare function pageLines(pages: FilePage[], window: PageLinesWindow): PageLinesAnswer;
51
- export declare function registerFilesTool(server: McpServer, client: LbbClient): void;
53
+ export declare function registerFilesTool(host: ToolHost, client: LbbClient): void;
52
54
  export {};
@@ -66,6 +66,7 @@ export function pageLines(pages, window) {
66
66
  height: page.height,
67
67
  lines_total: page.lines.length,
68
68
  lines_matching: matching,
69
+ ...(page.ocr_required ? { ocr_required: true } : {}),
69
70
  lines: shown,
70
71
  });
71
72
  if (stoppedAt !== undefined) {
@@ -112,9 +113,9 @@ async function fileSha(target, args) {
112
113
  const names = files.slice(0, 20).map((file) => file.name);
113
114
  throw new Error(`no file is named ${args.name}${names.length ? `; the graph has ${names.join(", ")}` : "; the graph has no files"}`);
114
115
  }
115
- export function registerFilesTool(server, client) {
116
- server.registerTool("lbb_files", {
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.",
116
+ export function registerFilesTool(host, client) {
117
+ host.register("lbb_files", {
118
+ description: "Files of the graph, read only. A file is a document the graph holds (its bytes and a Document entity). A file a workflow run wrote (a generated file, with an origin and an expires_at) has no Document and no Document IRI. It expires unless a person keeps it, so the list shows it only after that, and it is never evidence for a fact. 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
119
  inputSchema: {
119
120
  action: z.enum(["list", "pages"]),
120
121
  after: z
@@ -212,8 +213,12 @@ export function registerFilesTool(server, client) {
212
213
  const range = answer.to - answer.from > 1
213
214
  ? `pages ${answer.from} to ${answer.to - 1}`
214
215
  : `page ${answer.from}`;
216
+ const scanned = lines.pages.filter((entry) => entry.ocr_required).length;
217
+ const needsOcr = scanned
218
+ ? `; ${scanned === 1 ? "1 page needs" : `${scanned} pages need`} OCR, almost no text was read`
219
+ : "";
215
220
  return toolResult({
216
- summary: `${label}, ${range} of ${answer.page_count} (0-based): ${lines.shown} lines${lines.next ? ", more after next" : ""}`,
221
+ summary: `${label}, ${range} of ${answer.page_count} (0-based): ${lines.shown} lines${needsOcr}${lines.next ? ", more after next" : ""}`,
217
222
  data: {
218
223
  sha256: answer.sha256,
219
224
  parser: answer.parser,
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";
@@ -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?: {
@@ -1,3 +1,3 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
1
  import type { LbbClient } from "@littlebigbrain/client";
3
- export declare function registerRdfTool(server: McpServer, client: LbbClient): void;
2
+ import { type ToolHost } from "./approval.js";
3
+ export declare function registerRdfTool(host: ToolHost, client: LbbClient): void;
package/dist/rdf-tool.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { graphScope, detailSchema, advertiseUnion } from "./tool-contracts.js";
3
3
  import { contentHashKey, errorResult, run, scoped } from "./tool-runtime.js";
4
+ import { APPROVAL_TEXT, graphLabel, sparqlUpdateMessage, } from "./approval.js";
5
+ /**
6
+ * An update form that can remove statements: DELETE (DATA, WHERE, or with
7
+ * INSERT), CLEAR, DROP, and MOVE and COPY, which empty their target first.
8
+ * A word in a literal or an IRI also matches; that only asks once more.
9
+ */
10
+ const REMOVING_UPDATE = /\b(DELETE|CLEAR|DROP|MOVE|COPY)\b/i;
4
11
  const input = z.discriminatedUnion("action", [
5
12
  z
6
13
  .object({
@@ -33,9 +40,9 @@ const input = z.discriminatedUnion("action", [
33
40
  })
34
41
  .strict(),
35
42
  ]);
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.",
43
+ export function registerRdfTool(host, client) {
44
+ host.register("lbb_rdf", {
45
+ 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. An update with DELETE, CLEAR, DROP, MOVE or COPY asks the person to approve it first; the question quotes the operations that remove. ${APPROVAL_TEXT}`,
39
46
  inputSchema: advertiseUnion("action", input),
40
47
  annotations: {
41
48
  readOnlyHint: false,
@@ -43,11 +50,18 @@ export function registerRdfTool(server, client) {
43
50
  idempotentHint: true,
44
51
  openWorldHint: false,
45
52
  },
46
- }, (raw) => {
53
+ }, async (raw, extra) => {
47
54
  const parsed = input.safeParse(raw);
48
55
  if (!parsed.success)
49
56
  return errorResult(parsed.error);
50
57
  const args = parsed.data;
58
+ if (args.action === "update" && REMOVING_UPDATE.test(args.update)) {
59
+ const refused = await host.approve(extra, {
60
+ message: sparqlUpdateMessage(graphLabel(client, args.graph), args.update),
61
+ });
62
+ if (refused)
63
+ return refused;
64
+ }
51
65
  return run(client, `lbb_rdf.${args.action}`, args.detail, async () => {
52
66
  const target = scoped(client, args.graph);
53
67
  const { idempotency_key } = args;
@@ -0,0 +1,101 @@
1
+ import { type LbbClient } from "@littlebigbrain/client";
2
+ import { type ToolExtra, type ToolHost } from "./approval.js";
3
+ type Args = {
4
+ records?: Record<string, unknown>;
5
+ idempotencyKey?: string;
6
+ expectedGraphEpoch?: string;
7
+ graph?: string;
8
+ legacyInputs: boolean;
9
+ };
10
+ export declare function commitRecordsTool(host: ToolHost, client: LbbClient, extra: ToolExtra, args: Args): Promise<{
11
+ structuredContent: {
12
+ error: {
13
+ idempotency_key: string;
14
+ type: string;
15
+ code: string;
16
+ message: string;
17
+ outcome: string;
18
+ param: null;
19
+ request_id: null;
20
+ doc_url: null;
21
+ status: null;
22
+ } | {
23
+ idempotency_key?: string | undefined;
24
+ type: string;
25
+ code: string;
26
+ message: string;
27
+ param: string | null;
28
+ request_id: string | null;
29
+ doc_url: string | null;
30
+ status: number;
31
+ outcome?: undefined;
32
+ } | {
33
+ idempotency_key?: string | undefined;
34
+ type: string;
35
+ code: string;
36
+ message: string;
37
+ param: null;
38
+ request_id: null;
39
+ doc_url: null;
40
+ status: null;
41
+ outcome?: undefined;
42
+ };
43
+ };
44
+ content: {
45
+ type: "text";
46
+ text: string;
47
+ }[];
48
+ isError: true;
49
+ } | {
50
+ isError: true;
51
+ content: {
52
+ type: "text";
53
+ text: string;
54
+ }[];
55
+ structuredContent: {
56
+ error: {
57
+ idempotency_key?: string | undefined;
58
+ type: string;
59
+ code: string;
60
+ message: string;
61
+ message_truncated: boolean;
62
+ status: number | null;
63
+ param: string | null;
64
+ request_id: string | null;
65
+ doc_url: string | null;
66
+ };
67
+ };
68
+ } | {
69
+ isError: true;
70
+ content: {
71
+ type: "text";
72
+ text: string;
73
+ }[];
74
+ structuredContent: {
75
+ error: {
76
+ type: string;
77
+ code: string;
78
+ message: string;
79
+ outcome: string;
80
+ idempotency_key: string;
81
+ epoch: string;
82
+ commit_seq: number;
83
+ };
84
+ };
85
+ } | {
86
+ content: {
87
+ type: "text";
88
+ text: string;
89
+ }[];
90
+ structuredContent: {
91
+ summary: string;
92
+ data: {
93
+ idempotent_replay: boolean;
94
+ object: import("@littlebigbrain/client").components["schemas"]["RecordCommitObject"];
95
+ receipt: import("@littlebigbrain/client").components["schemas"]["RecordCommitReceipt"];
96
+ version: import("@littlebigbrain/client").components["schemas"]["RecordsVersion"];
97
+ };
98
+ };
99
+ isError?: undefined;
100
+ }>;
101
+ export {};
@@ -0,0 +1,150 @@
1
+ /** Records-specific MCP framing; semantic admission belongs to the server. */
2
+ import { Buffer } from "node:buffer";
3
+ import { LbbCapabilityError, } from "@littlebigbrain/client";
4
+ import { graphLabel, oneLine, } from "./approval.js";
5
+ import { HARD_OUTPUT_CHARS } from "./tool-contracts.js";
6
+ import { contentHashKey, enrichError, errorResult, scoped, ToolError, } from "./tool-runtime.js";
7
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
8
+ const nonempty = (value) => isObject(value)
9
+ ? Object.keys(value).length > 0
10
+ : Array.isArray(value) && value.length > 0;
11
+ function mayRemove(body) {
12
+ if (body.replace_scope === true)
13
+ return true;
14
+ for (const rows of [body.entities, body.relationships]) {
15
+ if (!Array.isArray(rows))
16
+ continue;
17
+ for (const row of rows) {
18
+ if (isObject(row) &&
19
+ (row.delete === true ||
20
+ row.asserted === false ||
21
+ nonempty(row.set) ||
22
+ nonempty(row.unset) ||
23
+ nonempty(row.remove)))
24
+ return true;
25
+ }
26
+ }
27
+ return false;
28
+ }
29
+ function approvalMessage(client, graph, body) {
30
+ const entities = Array.isArray(body.entities) ? body.entities.length : 0;
31
+ const relationships = Array.isArray(body.relationships)
32
+ ? body.relationships.length
33
+ : 0;
34
+ const action = body.replace_scope === true
35
+ ? "Replace the complete desired support"
36
+ : "Apply record edits that may replace or remove support";
37
+ return `${action} in ${graphLabel(client, graph)}, owner "${oneLine(body.owner ?? "application")}", scope "${oneLine(body.scope ?? "default")}" (${entities} entities, ${relationships} relationships)? Other owners' support is retained. This does not erase retained history.`;
38
+ }
39
+ function boundedError(value, key) {
40
+ const original = errorResult(value);
41
+ // A typed server error can also need receipt recovery. Keep its code/status
42
+ // unchanged, but retain the derived key rather than making callers guess it.
43
+ const identity = {
44
+ ...original.structuredContent.error,
45
+ ...(key !== undefined ? { idempotency_key: key } : {}),
46
+ };
47
+ const result = {
48
+ ...original,
49
+ structuredContent: { error: identity },
50
+ content: [
51
+ {
52
+ type: "text",
53
+ text: JSON.stringify({ error: identity }, null, 2),
54
+ },
55
+ ],
56
+ };
57
+ if (Buffer.byteLength(result.content[0].text, "utf8") <= HARD_OUTPUT_CHARS)
58
+ return result;
59
+ const source = result.structuredContent.error;
60
+ const short = (value, max = 512) => typeof value === "string" &&
61
+ Buffer.byteLength(JSON.stringify(value), "utf8") <= max
62
+ ? value
63
+ : null;
64
+ const error = {
65
+ type: short(source.type) ?? "tool_error",
66
+ code: short(source.code) ?? "record_error_output_limit",
67
+ message: "The complete server error exceeds the MCP output budget. Its code/status are retained; inspect the SDK or HTTP response for the full message. No complete commit receipt was returned.",
68
+ message_truncated: true,
69
+ status: typeof source.status === "number" && Number.isSafeInteger(source.status)
70
+ ? source.status
71
+ : null,
72
+ param: short(source.param),
73
+ request_id: short(source.request_id),
74
+ doc_url: short(source.doc_url),
75
+ ...(key !== undefined ? { idempotency_key: key } : {}),
76
+ };
77
+ const payload = { error };
78
+ return {
79
+ isError: true,
80
+ content: [{ type: "text", text: JSON.stringify(payload) }],
81
+ structuredContent: payload,
82
+ };
83
+ }
84
+ export async function commitRecordsTool(host, client, extra, args) {
85
+ let requestKey;
86
+ try {
87
+ if (args.legacyInputs)
88
+ throw new ToolError("record_payload_ambiguous", "mode=records cannot include legacy facts, feedback, retract, dry_run, observed_at or edge_idempotency arguments, even when empty or false");
89
+ if (!isObject(args.records) || args.records.version !== "records/1") {
90
+ throw new ToolError("record_version_unsupported", "mode=records requires a records object with explicit version records/1");
91
+ }
92
+ // Capture before approval/capability I/O. Do not strip unknown fields,
93
+ // normalize IRIs, bind friendly keys or copy the server's source policy.
94
+ const raw = JSON.stringify(args.records);
95
+ if (Buffer.byteLength(raw, "utf8") > 1024 * 1024)
96
+ throw new ToolError("record_payload_budget", "The records body exceeds the MCP one MiB request budget; use a bounded SDK request");
97
+ const body = JSON.parse(raw);
98
+ if (!isObject(body) || body.version !== "records/1")
99
+ throw new ToolError("record_version_unsupported", "the encoded records object must retain version records/1");
100
+ const graph = args.graph;
101
+ const epoch = args.expectedGraphEpoch;
102
+ const key = args.idempotencyKey ??
103
+ contentHashKey({ graph: graph ?? client.graphName }, { mode: "records", expected_graph_epoch: epoch, body });
104
+ if (key.trim().length === 0)
105
+ throw new ToolError("record_payload_invalid", "records idempotency_key must not be empty");
106
+ // This is an output-identity allowance, not a duplicate of HTTP key syntax.
107
+ // It leaves room to return the FULL original key if receipt recovery fails.
108
+ if (Buffer.byteLength(JSON.stringify(key), "utf8") > 8192)
109
+ throw new ToolError("record_payload_budget", "The idempotency key exceeds the MCP recovery-identity budget");
110
+ requestKey = key;
111
+ if (mayRemove(body)) {
112
+ const refused = await host.approve(extra, {
113
+ message: approvalMessage(client, graph, body),
114
+ });
115
+ if (refused)
116
+ return refused;
117
+ }
118
+ const result = await scoped(client, graph).records.commit(body, { idempotencyKey: key, expectedGraphEpoch: epoch });
119
+ const envelope = { summary: "lbb_commit.records", data: result };
120
+ const text = JSON.stringify(envelope);
121
+ if (Buffer.byteLength(text, "utf8") > HARD_OUTPUT_CHARS) {
122
+ // Acceptance is already known; do not relabel it as a failed mutation or
123
+ // an unknown outcome just because the full result cannot fit this tool.
124
+ const error = {
125
+ type: "tool_error",
126
+ code: "record_accepted_output_limit",
127
+ message: "The record commit was accepted, but its complete receipt exceeds the MCP output budget. Recover the original receipt with the same body/key through the SDK or HTTP API.",
128
+ outcome: "accepted",
129
+ idempotency_key: key,
130
+ epoch: result.receipt.epoch,
131
+ commit_seq: result.receipt.commit_seq,
132
+ };
133
+ const payload = { error };
134
+ return {
135
+ isError: true,
136
+ content: [{ type: "text", text: JSON.stringify(payload) }],
137
+ structuredContent: payload,
138
+ };
139
+ }
140
+ return {
141
+ content: [{ type: "text", text }],
142
+ structuredContent: envelope,
143
+ };
144
+ }
145
+ catch (error) {
146
+ return boundedError(error instanceof LbbCapabilityError
147
+ ? new ToolError("record_capability_unavailable", error.message)
148
+ : await enrichError(client, error), requestKey);
149
+ }
150
+ }
@@ -0,0 +1,3 @@
1
+ import type { LbbClient } from "@littlebigbrain/client";
2
+ import { type ToolHost } from "./approval.js";
3
+ export declare function registerReviewsTools(host: ToolHost, client: LbbClient): void;