@littlebigbrain/mcp 0.5.0 → 0.5.2

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 CHANGED
@@ -1,38 +1,52 @@
1
1
  # @littlebigbrain/mcp
2
2
 
3
- Eight task-shaped [MCP](https://modelcontextprotocol.io) tools that let Claude, Cursor, Codex, or any MCP client search, query, and write a [Little Big Brain](https://littlebigbrain.com) graph. Ships two ways: a hosted endpoint with OAuth sign-in, and a local stdio server.
3
+ [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for
4
+ [little big brain](https://littlebigbrain.com), a search platform for AI
5
+ applications such as chatbots, search tools, and agents.
4
6
 
5
- ## Hosted (OAuth) — recommended
7
+ Connect an MCP client to search your data, follow relationships, and read the
8
+ facts behind an answer. The tools also let you load data and define validation
9
+ rules.
6
10
 
7
- The client opens WorkOS sign-in; your machine never stores a Little Big Brain key. Point it at your stack:
11
+ [Documentation](https://docs.littlebigbrain.com/sdks/mcp/) ·
12
+ [Quickstart](https://docs.littlebigbrain.com/start/quickstart/) ·
13
+ [Issues](https://github.com/littlebigbrains/lbb-mcp/issues)
8
14
 
9
- ```json
10
- {
11
- "mcpServers": {
12
- "lbb": {
13
- "url": "https://mcp.littlebigbrain.com/mcp/<stack-slug>"
14
- }
15
- }
16
- }
15
+ ## Connect
16
+
17
+ Create a stack in the [console](https://cloud.littlebigbrain.com), then choose a
18
+ hosted connection or a local process.
19
+
20
+ ### Hosted connection
21
+
22
+ For clients that support remote MCP with OAuth, use this URL and sign in with
23
+ your little big brain account. Replace `<stack-slug>` with your stack's slug:
24
+
25
+ ```text
26
+ https://mcp.littlebigbrain.com/mcp/<stack-slug>
17
27
  ```
18
28
 
19
- Codex sends the URL as an OAuth `resource`, so use the origin plus a stack header instead:
29
+ For clients that use an `mcpServers` JSON configuration:
20
30
 
21
31
  ```json
22
32
  {
23
33
  "mcpServers": {
24
34
  "lbb": {
25
- "type": "http",
26
- "url": "https://mcp.littlebigbrain.com",
27
- "headers": { "X-LBB-Stack": "<stack-slug>" }
35
+ "url": "https://mcp.littlebigbrain.com/mcp/<stack-slug>"
28
36
  }
29
37
  }
30
38
  }
31
39
  ```
32
40
 
33
- ## Local (stdio)
41
+ This connection uses account sign-in and does not require a stack API key.
42
+ The [connection guide](https://docs.littlebigbrain.com/sdks/mcp/#hosted-streamable-http-oauth)
43
+ includes client-specific setup, including the origin URL and `X-LBB-Stack` header
44
+ for Codex.
45
+
46
+ ### Local process
34
47
 
35
- Run against any data-plane endpoint with a stack API key:
48
+ Requires Node.js 18+. Copy the complete endpoint and a stack API key from
49
+ **Connect** in the console, then add them to your client's MCP configuration:
36
50
 
37
51
  ```json
38
52
  {
@@ -41,140 +55,128 @@ Run against any data-plane endpoint with a stack API key:
41
55
  "command": "npx",
42
56
  "args": ["-y", "@littlebigbrain/mcp"],
43
57
  "env": {
44
- "LBB_BASE_URL": "https://0abc1def--production.db.eu.littlebigbrain.com",
45
- "LBB_API_KEY": "lbb_sk_live_..."
58
+ "LBB_BASE_URL": "https://<your-complete-stack-host>",
59
+ "LBB_API_KEY": "<your-stack-api-key>"
46
60
  }
47
61
  }
48
62
  }
49
63
  }
50
64
  ```
51
65
 
52
- Set `LBB_GRAPH` or `LBB_BRANCH` to target a scope other than `main`.
53
- `LBB_BASE_URL` has no hosted default: copy `endpoint_url` from the stack's
54
- Connect page. The MCP process exits with a configuration error when it is
55
- missing.
66
+ The client starts the server and communicates through standard input and output
67
+ (stdio). Keep the key in your local configuration. Set `LBB_GRAPH` or
68
+ `LBB_BRANCH` to change the default scope from `main`.
56
69
 
57
- ## Tools
70
+ ## Run a first query
58
71
 
59
- | Tool | Use it for |
60
- | --- | --- |
61
- | `lbb_inspect` | graph discovery, complete paginated ontology/schema, publication status, entity, state, history, and provenance |
62
- | `lbb_rdf` | import full RDF/OWL or add axioms using INSERT DATA |
63
- | `lbb_query` | SPARQL text, structured SPARQL bodies, and canned analysis |
64
- | `lbb_commit` | facts, properties, and embeddings |
65
- | `lbb_observe` | conversation episodes plus reviewed extraction |
66
- | `lbb_branch` | isolation branches and validated merge |
67
- | `lbb_models` | shadow evaluation and training datasets |
68
- | `lbb_configure` | native ontology definition/evolution and SHACL preview/publication |
69
-
70
- Read tools return compact structured envelopes by default — use `detail`, `row_limit`, and returned cursors to page without silently truncating. Write tools derive an idempotency key unless you provide one.
71
-
72
- Both `lbb_query` SPARQL modes (`sparql` and `structured`) support retained commit reads
73
- through `as_of_commit_seq`. When omitted, the connector pins the current head
74
- commit and reuses it for cursor pages. Valid-time `as_of` is unsupported and is
75
- rejected before an API call, including when carried in an old cursor. Start a
76
- new query without that selector or choose a retained commit sequence.
77
-
78
- ## Create and evolve an ontology through MCP
79
-
80
- Start with `lbb_inspect action=guide`; `action=graphs` helps select an existing
81
- scope, and a missing graph returns bootstrap guidance. Decide what questions
82
- the graph must answer before choosing classes and relations. Distinguish
83
- source-backed facts from hypotheses, and preserve evidence and dates.
84
-
85
- Native metadata and stored RDF axioms are separate:
86
-
87
- - `lbb_configure action=define_ontology` accepts a friendly `spec`, including
88
- class `super_types`. Unknown spec fields fail explicitly. `lbb_json` expects
89
- an internal serialized ontology, not a friendly spec. Raw OWL supplied to
90
- configure is reduced to native metadata; it is not stored as a full document.
91
- - `lbb_rdf action=import` stores the complete Turtle, N-Triples, N-Quads, or TriG
92
- document as queryable graph facts, including RDF lists, annotations, and OWL
93
- axioms. Pass `source`; the published RDF tier supports only the default RDF
94
- graph. Dataset formats must contain only default-graph quads. The first RDF
95
- data write selects RDF-native storage, which refuses
96
- later property-graph commits; choose the write workflow before bootstrap.
97
- - `lbb_configure action=evolve_ontology` supports explicit native changes,
98
- including `add_super_types`. `dry_run: true` previews define/evolve/publish;
99
- the same flag previews `lbb_commit mode=facts` without writing.
100
- - `lbb_rdf action=update` submits SPARQL Update unchanged; currently only
101
- `INSERT DATA` is supported. DELETE, WHERE, and graph replacement are refused.
102
- Re-importing is
103
- additive and does not remove obsolete axioms. Content-based retry keys are
104
- automatic; use a new explicit key for an intentional repeat after other edits.
105
-
106
- For example, add a superclass without a browser or RDF conversion:
72
+ This example stores three facts in a graph named `quickstart`: a service writes
73
+ to a database, and each has a label. The graph is created on its first write.
74
+
75
+ Ask your client to call `lbb_rdf` with these arguments:
107
76
 
108
77
  ```json
109
78
  {
110
- "action": "update",
111
- "update": "INSERT DATA { <urn:Person> <http://www.w3.org/2000/01/rdf-schema#subClassOf> <urn:Contact> }"
79
+ "action": "import",
80
+ "graph": "quickstart",
81
+ "format": "ntriples",
82
+ "source": "<https://example.org/auth-service> <https://example.org/writesTo> <https://example.org/user-db> .\n<https://example.org/auth-service> <http://www.w3.org/2000/01/rdf-schema#label> \"Auth Service\" .\n<https://example.org/user-db> <http://www.w3.org/2000/01/rdf-schema#label> \"User Database\" .",
83
+ "idempotency_key": "mcp-quickstart-v1"
112
84
  }
113
85
  ```
114
86
 
115
- Removing or replacing RDF axioms requires native bounded update support in the
116
- engine. Until then, import a revised document into a new versioned LBB graph,
117
- verify it, and explicitly switch consumers. Do not implicitly delete the original.
118
-
119
- `publish_schema` activates SHACL shapes against unchanged native metadata.
120
- Its preview checks parsing and compatibility without writing objects or
121
- scheduling jobs; it does not audit the entire graph. Preview restrictive
122
- native edits with evolve, resolve conflicts, then apply. For restrictive
123
- SHACL, use warn → inspect conformance → repair → reject.
124
-
125
- After applying, inspect `action=publication`, then verify both asserted axioms
126
- and expected inferred answers. `lbb_query mode=sparql` accepts explicit
127
- `entailment: "none" | "subclass" | "rdfs" | "owl"` (default `none`),
128
- `consistency: "eventual" | "strong"`, and `min_indexed_seq`. Cursors retain
129
- these controls. OWL is the server's supported inference profile, not arbitrary
130
- OWL DL. An upload acknowledgement is not proof of successful reasoning.
131
-
132
- To read asserted axioms in the default graph, query with `entailment: "none"`
133
- and follow all returned row cursors:
134
-
135
- ```sparql
136
- SELECT ?s ?p ?o WHERE {
137
- ?s ?p ?o
138
- } ORDER BY ?s ?p ?o
87
+ Now ask **which database does Auth Service write to?** The `lbb_query` call is:
88
+
89
+ ```json
90
+ {
91
+ "mode": "sparql",
92
+ "graph": "quickstart",
93
+ "consistency": "strong",
94
+ "query": "SELECT ?database WHERE { <https://example.org/auth-service> <https://example.org/writesTo> ?db . ?db <http://www.w3.org/2000/01/rdf-schema#label> ?database } ORDER BY ?database LIMIT 10"
95
+ }
139
96
  ```
140
97
 
141
- `lbb_inspect action=ontology` and `action=schema` page complete native metadata
142
- with `page_size` (default 50, maximum 500), optional `section`, and `cursor`.
143
- Pass the returned `next` arguments until absent. These pages preserve nested
144
- values even with `detail=compact`; they never replace the remainder with a
145
- suggestion to repeat `detail=full`. If a single entry is too large, the result
146
- contains `entry_fragment`: concatenate `serialized_json` by `char_offset`, then
147
- JSON-parse the completed entry. Changed metadata invalidates the cursor rather
148
- than mixing versions. Restart inspection after applying edits.
98
+ The result contains one row on a fresh graph:
99
+
100
+ | database |
101
+ | --- |
102
+ | User Database |
103
+
104
+ The query follows a stored relationship to its database label. Strong consistency
105
+ lets it read the import without waiting for a background index job.
106
+
107
+ For your own data, start with `lbb_inspect` using `action: "guide"` or
108
+ `action: "ontology"`. Imported Resource Description Framework (RDF) data keeps
109
+ its original identifiers; inspect it with SPARQL when choosing query predicates.
110
+
111
+ ## Tools
112
+
113
+ | Tool | Purpose |
114
+ | --- | --- |
115
+ | `lbb_inspect` | Read the schema, graph status, entities, history, and evidence. |
116
+ | `lbb_query` | Run SPARQL queries, search by meaning, or request summary statistics. |
117
+ | `lbb_rdf` | Import RDF documents or add facts with SPARQL `INSERT DATA`. |
118
+ | `lbb_embeddings` | Inspect search setup and preview the text to embed. |
119
+ | `lbb_embeddings_manage` | Set up or refresh embeddings, or change the embedding model. |
120
+ | `lbb_embeddings_delete` | Delete an embedding and its stored vectors. |
121
+ | `lbb_commit` | Write or retract JSON facts, or record search feedback. |
122
+ | `lbb_configure` | Define record types and relationships, or publish validation rules. |
123
+ | `lbb_branch` | Create branches and merge reviewed changes. |
124
+ | `lbb_observe` | Store conversation episodes and reviewed facts extracted from them. |
125
+ | `lbb_evals` | Label query results and check whether later queries return the expected answers. |
126
+ | `lbb_models` | Compare retrieval settings and read model training datasets. |
127
+
128
+ See [search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/)
129
+ for embedding setup. To define constraints with the Shapes Constraint Language
130
+ (SHACL), see [query and validation](https://docs.littlebigbrain.com/guides/sparql-and-shacl/).
131
+
132
+ RDF imports and JSON facts use different write workflows. A graph first written
133
+ through `lbb_rdf` does not accept JSON fact writes through `lbb_commit`, or branch
134
+ forks and merges. Choose the workflow when creating the graph. RDF updates
135
+ support additive `INSERT DATA`;
136
+ see the [RDF guide](https://docs.littlebigbrain.com/guides/load-rdf/) for format and
137
+ update limits.
138
+
139
+ ## Pages and saved queries
140
+
141
+ When a result includes `next`, pass those arguments to the same tool to read the
142
+ next page. Continue until `next` is absent. `row_limit` sets a maximum; large
143
+ values can make a page shorter.
144
+
145
+ SPARQL queries keep the same commit across pages. Save the query and its returned
146
+ commit sequence to check an answer later, then use `as_of_commit_seq` to select
147
+ that version. See [history and replay](https://docs.littlebigbrain.com/guides/time-travel-audit/)
148
+ for retention and evidence handling.
149
149
 
150
150
  ## Embed the server
151
151
 
152
- For self-hosting behind your own auth, the package also serves the tools over HTTP:
152
+ To serve the tools from your own Node.js process:
153
153
 
154
154
  ```ts
155
155
  import { createMcpHttpServer } from "@littlebigbrain/mcp";
156
156
 
157
157
  createMcpHttpServer({
158
- baseUrl: "https://0abc1def--production.db.eu.littlebigbrain.com",
158
+ baseUrl: process.env.LBB_BASE_URL!,
159
159
  mcpPath: "/mcp",
160
160
  allowedHosts: ["127.0.0.1", "localhost", "::1"],
161
161
  }).listen(8080, "127.0.0.1");
162
162
  ```
163
163
 
164
- The embedded server passes a key bearer to the data plane; the hosted endpoint's OAuth and ownership layer is served separately by the Little Big Brain API.
165
-
166
- Full tool schemas and examples: [docs.littlebigbrain.com/sdks/mcp](https://docs.littlebigbrain.com/sdks/mcp/).
164
+ Clients connect to `http://127.0.0.1:8080/mcp` and send a stack API key in
165
+ `Authorization: Bearer <key>`. See the
166
+ [Node.js integration guide](https://docs.littlebigbrain.com/sdks/mcp/#embed-in-a-node-process)
167
+ for HTTP options and integration with an existing MCP server.
167
168
 
168
- ## Local end-to-end ontology check
169
+ ## Development
169
170
 
170
- From the repository root, build the server and SDKs, then opt into the isolated
171
- real-server MCP test (it creates and removes its own temporary data root):
171
+ From a clone of this repository:
172
172
 
173
173
  ```sh
174
- cargo build -p lbb-server
175
- npm run build -w @littlebigbrain/client
176
- LBB_TEST_SERVER_BIN="$PWD/target/debug/lbb-server" npm test -w @littlebigbrain/mcp
174
+ npm ci
175
+ npm run build
176
+ npm run typecheck
177
+ npm test
177
178
  ```
178
179
 
179
- The test verifies native hierarchy evolution, preservation of RDF annotations,
180
- subclass/inverse inference, additive edits, and refusal of unsupported deletion.
180
+ ## License
181
+
182
+ [Apache-2.0](LICENSE).
package/dist/index.d.ts CHANGED
@@ -1,3 +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
4
  export { createMcpHttpServer, type McpHttpServerOptions, } from "./http-server.js";
@@ -0,0 +1,33 @@
1
+ /** Local opt-in query measurements. No event contains query or response data. */
2
+ export type McpQueryStage = "query_pin_metadata" | "query_http_total" | "query_results_parse" | "query_envelope" | "query_render";
3
+ export interface McpQueryStageEvent {
4
+ stage: McpQueryStage;
5
+ durationMs: number;
6
+ outcome: "success" | "error";
7
+ mode: "sparql" | "structured";
8
+ continuation: boolean;
9
+ row_limit: number;
10
+ offset: number;
11
+ received_rows?: number;
12
+ returned_rows?: number;
13
+ cursor_bytes?: number;
14
+ text_bytes?: number;
15
+ }
16
+ export interface LbbServerOptions {
17
+ /** Query text only; fitting and rendering always use this same format. */
18
+ queryTextFormat?: "compact" | "pretty";
19
+ timing?: {
20
+ /** Monotonic milliseconds; defaults to performance.now(). */
21
+ now?: () => number;
22
+ observe: (event: Readonly<McpQueryStageEvent>) => void;
23
+ };
24
+ }
25
+ type QueryCounts = Pick<McpQueryStageEvent, "received_rows" | "returned_rows" | "cursor_bytes" | "text_bytes">;
26
+ type QueryIdentity = Pick<McpQueryStageEvent, "mode" | "continuation" | "row_limit" | "offset">;
27
+ /** Each tool call owns its small counter record; servers may serve concurrently. */
28
+ export declare function queryTiming(options: LbbServerOptions, identity: QueryIdentity): {
29
+ counts(update: QueryCounts): void;
30
+ measure<T>(stage: McpQueryStage, operation: () => T): T;
31
+ measureAsync<T>(stage: McpQueryStage, operation: () => Promise<T>): Promise<T>;
32
+ };
33
+ export {};
@@ -0,0 +1,95 @@
1
+ function count(value) {
2
+ return Number.isFinite(value)
3
+ ? Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.floor(value)))
4
+ : 0;
5
+ }
6
+ /** Each tool call owns its small counter record; servers may serve concurrently. */
7
+ export function queryTiming(options, identity) {
8
+ const timing = options.timing;
9
+ const counts = {};
10
+ const now = () => {
11
+ try {
12
+ const value = timing?.now?.() ?? performance.now();
13
+ return Number.isFinite(value) ? value : performance.now();
14
+ }
15
+ catch {
16
+ return performance.now();
17
+ }
18
+ };
19
+ const emit = (stage, start, outcome) => {
20
+ if (!timing)
21
+ return;
22
+ const elapsed = now() - start;
23
+ const event = {
24
+ stage,
25
+ durationMs: Number.isFinite(elapsed)
26
+ ? Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, elapsed))
27
+ : 0,
28
+ outcome,
29
+ mode: identity.mode,
30
+ continuation: identity.continuation,
31
+ row_limit: count(identity.row_limit),
32
+ offset: count(identity.offset),
33
+ ...counts,
34
+ };
35
+ try {
36
+ // Async callbacks also remain isolated even though observation is not
37
+ // awaited and the public callback contract does not require a Promise.
38
+ const pending = timing.observe(event);
39
+ if (pending &&
40
+ typeof pending.then === "function") {
41
+ void Promise.resolve(pending).catch(() => { });
42
+ }
43
+ }
44
+ catch {
45
+ // A local observer cannot turn a successful query into a failure.
46
+ }
47
+ };
48
+ return {
49
+ counts(update) {
50
+ if (!timing)
51
+ return;
52
+ // Explicit keys prevent caller-supplied objects from adding fields.
53
+ for (const key of [
54
+ "received_rows",
55
+ "returned_rows",
56
+ "cursor_bytes",
57
+ "text_bytes",
58
+ ]) {
59
+ const value = update[key];
60
+ if (value !== undefined)
61
+ counts[key] = count(value);
62
+ }
63
+ },
64
+ measure(stage, operation) {
65
+ if (!timing)
66
+ return operation();
67
+ const start = now();
68
+ let outcome = "error";
69
+ try {
70
+ const result = operation();
71
+ outcome = "success";
72
+ return result;
73
+ }
74
+ finally {
75
+ emit(stage, start, outcome);
76
+ }
77
+ },
78
+ measureAsync(stage, operation) {
79
+ if (!timing)
80
+ return operation();
81
+ const start = now();
82
+ return (async () => {
83
+ let outcome = "error";
84
+ try {
85
+ const result = await operation();
86
+ outcome = "success";
87
+ return result;
88
+ }
89
+ finally {
90
+ emit(stage, start, outcome);
91
+ }
92
+ })();
93
+ },
94
+ };
95
+ }
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { LbbClient } from "@littlebigbrain/client";
3
+ import type { LbbServerOptions } from "./query-observer.js";
3
4
  /** Build an MCP server exposing the little big brain tool belt, bound to one client. */
4
- export declare function buildLbbServer(client: LbbClient): McpServer;
5
+ export declare function buildLbbServer(client: LbbClient, options?: LbbServerOptions): McpServer;
package/dist/server.js CHANGED
@@ -3,8 +3,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { registerLbbTools } from "./tools.js";
4
4
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
5
5
  /** Build an MCP server exposing the little big brain tool belt, bound to one client. */
6
- export function buildLbbServer(client) {
6
+ export function buildLbbServer(client, options = {}) {
7
7
  const server = new McpServer({ name: "lbb", version });
8
- registerLbbTools(server, client);
8
+ registerLbbTools(server, client, options);
9
9
  return server;
10
10
  }
@@ -14,6 +14,12 @@ export const MUTATING = {
14
14
  destructiveHint: false,
15
15
  openWorldHint: false,
16
16
  };
17
+ export const DESTRUCTIVE = {
18
+ readOnlyHint: false,
19
+ destructiveHint: true,
20
+ idempotentHint: true,
21
+ openWorldHint: false,
22
+ };
17
23
  export const detailSchema = z
18
24
  .enum(["compact", "standard", "full"])
19
25
  .optional()
@@ -481,11 +487,71 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
481
487
  .nonnegative()
482
488
  .optional()
483
489
  .describe("Snapshot pin: run the query as of this commit_seq. Errors if past head."),
490
+ request: z
491
+ .string()
492
+ .optional()
493
+ .describe("The user's own words behind this query (managed evals). When present the server records an eval trace and the result carries its trace_id; label the rows valid or not with lbb_evals action=label. Omit on continuation pages."),
484
494
  row_limit: rowLimitSchema,
485
495
  cursor: cursorSchema,
486
496
  ...readScope,
487
497
  })
488
498
  .strict(),
499
+ z
500
+ .object({
501
+ mode: z.literal("search"),
502
+ embedding: z
503
+ .string()
504
+ .optional()
505
+ .describe("One embedding by name (see lbb_embeddings action=list). Omit it to search every searchable class of the graph; each hit names its class."),
506
+ text: z
507
+ .string()
508
+ .optional()
509
+ .describe("The query text (required); embedded with the graph's model."),
510
+ top_k: z.number().int().positive().max(200).optional(),
511
+ probe: z
512
+ .number()
513
+ .int()
514
+ .positive()
515
+ .optional()
516
+ .describe("Clusters to read (default 4·√clusters, at least 8). More reads cost latency and find more."),
517
+ include: z
518
+ .array(z.enum(["text"]))
519
+ .optional()
520
+ .describe("text: return the embedded text of each hit."),
521
+ filter: z
522
+ .array(z
523
+ .object({
524
+ class: z
525
+ .union([z.string(), z.array(z.string())])
526
+ .optional()
527
+ .describe("A class condition: one class IRI or a list (any of); subclasses too. One class condition per search."),
528
+ via: z
529
+ .string()
530
+ .optional()
531
+ .describe("A relationship condition: the relationship, a local name (calls), prefix:name, or <iri>."),
532
+ to: z
533
+ .union([z.string(), z.array(z.string())])
534
+ .optional()
535
+ .describe("With via: the entity, an IRI or a name (its label; a spelling slip resolves when one entity is clearly closest), or a list (any of)."),
536
+ direction: z
537
+ .enum(["out", "in"])
538
+ .optional()
539
+ .describe("With via: out (default), the hit links to the entity; in, the entity links to the hit."),
540
+ })
541
+ .strict())
542
+ .optional()
543
+ .describe('Conditions every hit must meet: {"class": …} or {"via": …, "to": …}. The response\'s filter shows how each condition resolved; an unknown relationship or name answers with the options.'),
544
+ explain: z
545
+ .boolean()
546
+ .optional()
547
+ .describe("Plan without running: the scope, the resolved filter, and the allowed count. No model call."),
548
+ request: z
549
+ .string()
550
+ .optional()
551
+ .describe("The user's own words (managed evals): records an eval trace; label it with lbb_evals action=label."),
552
+ ...readScope,
553
+ })
554
+ .strict(),
489
555
  z
490
556
  .object({
491
557
  mode: z.literal("analyze"),
@@ -311,104 +311,94 @@ export function envelope(label, value, detailArg, next) {
311
311
  next: { ...(next ?? {}), detail: "full" },
312
312
  };
313
313
  }
314
- export function queryEnvelope(label, value, detailArg, rowPage, next, repage) {
315
- const detail = normalizeDetail(detailArg);
316
- const limits = compactLimits(detail);
317
- const state = { truncated: false };
318
- const returned = rowPage?.returned;
319
- const rowCap = rowPage
320
- ? Math.max(limits.maxItems, rowPage.returned)
321
- : limits.maxItems;
322
- let data = truncateValue(value, { ...limits, maxItems: rowCap }, state);
323
- const partialRows = rowPage
324
- ? rowPage.returned < rowPage.total || rowPage.has_more
325
- : false;
326
- const rowText = rowPage
327
- ? rowPage.returned < rowPage.total
328
- ? `returned ${rowPage.returned} of ${rowPage.total} rows`
329
- : `returned ${rowPage.returned} rows`
330
- : undefined;
331
- const serverFlags = serverTruncationFlags(value);
332
- const serverTruncated = serverFlags.length > 0;
333
- const serverText = serverTruncated
334
- ? ` [server-truncated: ${serverFlags.join(", ")}]`
335
- : "";
336
- let result = {
337
- summary: rowText
338
- ? `${label}: ${rowText}${serverText}${state.truncated ? " [truncated output]" : ""}`
339
- : defaultSummary(label, value, state.truncated),
340
- data,
341
- counts: countsFor(value),
342
- row_page: rowPage,
343
- truncated: state.truncated || partialRows || serverTruncated || undefined,
344
- next: partialRows && next
345
- ? next
346
- : state.truncated && nextDetail(detail)
347
- ? { detail: nextDetail(detail) }
348
- : next,
349
- };
350
- let text = JSON.stringify(result, null, 2);
351
- if (text.length <= HARD_OUTPUT_CHARS)
352
- return result;
353
- // The full result overflows one MCP tool result, so the displayed rows are
354
- // capped to fit. `row_page`/`counts` still describe the *server* page, so
355
- // reporting only those reads as "every row delivered" even when the display
356
- // was cut — the recurring MCP false-positive. So: state shown-vs-returned
357
- // explicitly (`rows_shown`), and give advice that matches reality —
358
- // * server itself withheld rows (partialRows): page with the existing cursor;
359
- // * server returned the complete set but it is too big: page the same set at
360
- // a smaller row_limit via a fresh cursor (offered here as `next`) or narrow
361
- // with HAVING.
362
- // "page with the cursor" is never suggested unless a cursor is actually given.
363
- const remedy = partialRows
364
- ? " page with the cursor for the remaining rows"
365
- : repage
366
- ? " re-run with the returned cursor to page the full set at a smaller row_limit, or add a HAVING filter to narrow the groups"
367
- : " re-run with a lower row_limit to page the full set, or add a HAVING filter to narrow the groups";
368
- const capNote = (shown) => returned !== undefined && shown < returned
369
- ? ` [MCP showed ${shown} of ${returned} rows — over the ${HARD_OUTPUT_CHARS}-char output budget]`
370
- : " [hard-capped for MCP output]";
371
- for (const cap of [200, 100, 50, 25, 10, 5, 3]) {
372
- const hardState = { truncated: true };
373
- data = truncateValue(value, { maxItems: cap, maxString: 160 }, hardState);
374
- const shown = returned !== undefined ? Math.min(cap, returned) : cap;
375
- const hardNext = partialRows
376
- ? next
377
- : repage
378
- ? continuationNext(repage, shown, Math.max(1, shown))
314
+ export function queryEnvelope(label, value, detailArg, rowPage, next, repage, options = {}) {
315
+ // A query page is data, not a preview: keep entire terms/values and page
316
+ // the rows themselves to fit the wire budget. The cursor must advance by
317
+ // what the caller actually received, even inside a partial server page.
318
+ const source = value;
319
+ const results = source?.results;
320
+ const key = Array.isArray(results?.bindings)
321
+ ? "bindings"
322
+ : Array.isArray(source?.groups) && source.groups.length > 0
323
+ ? "groups"
324
+ : Array.isArray(source?.solutions)
325
+ ? "solutions"
379
326
  : undefined;
380
- result = {
381
- summary: rowText
382
- ? `${label}: ${rowText}${serverText}${capNote(shown)} —${remedy}`
383
- : `${label}${capNote(shown)} —${remedy}`,
384
- data,
385
- counts: countsFor(value),
327
+ const rows = key === "bindings" ? results?.bindings : key ? source[key] : undefined;
328
+ if (!rowPage || !repage || !Array.isArray(rows)) {
329
+ return {
330
+ ...envelope(label, value, detailArg, next),
386
331
  row_page: rowPage,
387
- rows_shown: returned !== undefined ? shown : undefined,
388
- truncated: true,
389
- next: hardNext,
332
+ ...(options.notes?.length ? { notes: options.notes } : {}),
390
333
  };
391
- text = JSON.stringify(result, null, 2);
392
- if (text.length <= HARD_OUTPUT_CHARS)
393
- return result;
394
334
  }
395
- return {
396
- summary: rowText
397
- ? `${label}: ${rowText}${serverText}${capNote(0)} —${remedy}`
398
- : `${label}${capNote(0)} —${remedy}`,
399
- data: {
400
- note: "The response was too large for the MCP tool result even at the minimum row cap. Page with a smaller row_limit or add a HAVING filter to narrow the groups.",
401
- preview: text.slice(0, 20_000),
402
- },
403
- counts: countsFor(value),
404
- row_page: rowPage,
405
- rows_shown: returned !== undefined ? 0 : undefined,
406
- truncated: true,
407
- next: partialRows
408
- ? next
409
- : repage
410
- ? continuationNext(repage, 0, 1)
335
+ if (rows.length !== rowPage.returned) {
336
+ throw new Error("Query row count does not match its page; refusing a cursor that could skip rows.");
337
+ }
338
+ const flags = serverTruncationFlags(value);
339
+ const build = (count) => {
340
+ const hasMore = count < rows.length || rowPage.has_more;
341
+ const page = {
342
+ ...rowPage,
343
+ returned: count,
344
+ has_more: hasMore,
345
+ next_offset: hasMore ? rowPage.offset + count : undefined,
346
+ };
347
+ const selected = rows.slice(0, count);
348
+ const data = key === "bindings"
349
+ ? { ...source, results: { ...results, bindings: selected } }
350
+ : { ...source, [key]: selected, row_page: page };
351
+ return {
352
+ summary: `${label}: returned ${count}${count < page.total ? ` of ${page.total}` : ""} rows${flags.length ? ` [server-truncated: ${flags.join(", ")}]` : ""}${count < rows.length ? " [byte-bounded page; continue with the cursor]" : ""}`,
353
+ data,
354
+ counts: countsFor(data),
355
+ row_page: page,
356
+ rows_shown: count < rows.length ? count : undefined,
357
+ truncated: hasMore || flags.length > 0 || undefined,
358
+ next: hasMore
359
+ ? continuationNext(repage, page.offset + count, repage.row_limit)
411
360
  : undefined,
361
+ ...(options.notes?.length ? { notes: options.notes } : {}),
362
+ };
363
+ };
364
+ // Retain the existing reserve, but include actual normalization notes in
365
+ // every fit so even a large note list cannot push the final text over cap.
366
+ // Count UTF-8 bytes, including multibyte literals, not JavaScript code units.
367
+ const fits = (page) => Buffer.byteLength(serializeQueryEnvelope(page, options.textFormat), "utf8") <=
368
+ HARD_OUTPUT_CHARS - 2048;
369
+ const full = build(rows.length);
370
+ if (fits(full))
371
+ return full;
372
+ let lo = 0;
373
+ let hi = rows.length;
374
+ while (lo < hi) {
375
+ const mid = Math.ceil((lo + hi) / 2);
376
+ if (fits(build(mid)))
377
+ lo = mid;
378
+ else
379
+ hi = mid - 1;
380
+ }
381
+ if (lo === 0) {
382
+ throw new Error(rows.length === 0
383
+ ? "Query page metadata or normalization notes exceed the MCP output budget. Narrow the query or use the SPARQL HTTP API; no rows were skipped."
384
+ : "One query row exceeds the MCP output budget. Project fewer fields or use the SPARQL HTTP API for this value; no rows were skipped.");
385
+ }
386
+ return build(lo);
387
+ }
388
+ /** One formatter for query page fitting and the text returned to MCP clients. */
389
+ export function serializeQueryEnvelope(value, format = "compact") {
390
+ return format === "pretty"
391
+ ? JSON.stringify(value, null, 2)
392
+ : JSON.stringify(value);
393
+ }
394
+ export function queryToolResult(value, format) {
395
+ const text = serializeQueryEnvelope(value, format);
396
+ if (Buffer.byteLength(text, "utf8") > HARD_OUTPUT_CHARS) {
397
+ throw new Error("Query response exceeds the MCP output budget. Narrow the query or use the SPARQL HTTP API; no rows were skipped.");
398
+ }
399
+ return {
400
+ content: [{ type: "text", text }],
401
+ structuredContent: value,
412
402
  };
413
403
  }
414
404
  export function toolResult(value) {
@@ -695,6 +685,7 @@ export async function guide(scopedClient) {
695
685
  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.",
696
686
  inspect: "Use lbb_inspect for ontology, schema metadata, the published conformance report, metadata, state/history/why, and this guide. Use lbb_query with SPARQL property paths for exact path queries.",
697
687
  ontology_decorations: "lbb_inspect action=ontology returns a decoration_status catalog: each ontology decoration is enforced (the engine acts on it — state_reducer, value_type, super_types, properties, supernode_policy; cardinality, which GET /v1/ontology/conformance audits as sh:maxCount; and inverse_name/symmetric, which SPARQL resolves as relation aliases — an inverse name is queryable directly (lowered to ^forward, no stored inverse triple) and a symmetric relation matches both directions), advisory (transitive, temporal_semantics, required), or reserved (stored but unwired — default_weight, resolvable, alias/embedding_fields). You can also always reverse any relation in SPARQL by flipping the triple pattern or using ^forward. Each relation_def also carries edge_count — the number of current edges of that relation in this branch's snapshot — so you can tell at a glance which declared relations are actually populated (edge_count 0 = declared but unused) without a separate summary call.",
688
+ 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.",
698
689
  query: "Use lbb_query for structured SPARQL-subset bodies, SPARQL text, and canned analysis. SPARQL is the only query surface. A mode=structured body is { patterns: [{ subject, predicate, object }], filters?, group_by?, group_keys?, aggregates?, having? }; a pattern `predicate` is a relation name and is case-insensitive. mode=structured GROUP BY is not limited to entity identity: group_keys can key on a typed scalar property or calendar bucket. In SPARQL text, relations are <https://littlebigbrain.com/r/NAME> and types <https://littlebigbrain.com/class/NAME>. Each query pins one published watermark.",
699
690
  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.",
700
691
  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.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { LbbClient } from "@littlebigbrain/client";
3
- export declare function registerLbbTools(server: McpServer, client: LbbClient): void;
3
+ import { type LbbServerOptions } from "./query-observer.js";
4
+ export declare function registerLbbTools(server: McpServer, client: LbbClient, options?: LbbServerOptions): void;
package/dist/tools.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { metadataPage } from "./metadata-pages.js";
3
3
  import { registerRdfTool } from "./rdf-tool.js";
4
- import { IDEMPOTENT_WRITE, MUTATING, READ_ONLY, configureInputSchema, configureWireSchema, detailSchema, graphScope, inspectInputSchema, inspectWireSchema, jsonObjectSchema, queryInputSchema, queryWireSchema, searchFeedbackSchema, } from "./tool-contracts.js";
5
- import { analyze, assertCursorScope, contentHashKey, decodeQueryCursor, effectiveRowLimit, enrichError, errorResult, guide, normalizeDetail, normalizeLbbIris, ontologyDefineBody, queryCommitPin, queryEnvelope, requireString, rowPageFrom, rowPageNext, run, scoped, stableJson, toolResult, } from "./tool-runtime.js";
6
- export function registerLbbTools(server, client) {
4
+ import { queryTiming } from "./query-observer.js";
5
+ import { DESTRUCTIVE, IDEMPOTENT_WRITE, MUTATING, READ_ONLY, configureInputSchema, configureWireSchema, detailSchema, graphScope, inspectInputSchema, inspectWireSchema, jsonObjectSchema, queryInputSchema, queryWireSchema, searchFeedbackSchema, } from "./tool-contracts.js";
6
+ import { analyze, assertCursorScope, contentHashKey, decodeQueryCursor, effectiveRowLimit, enrichError, errorResult, guide, normalizeDetail, normalizeLbbIris, ontologyDefineBody, queryCommitPin, queryEnvelope, queryToolResult, requireString, rowPageFrom, rowPageNext, run, scoped, stableJson, toolResult, } from "./tool-runtime.js";
7
+ export function registerLbbTools(server, client, options = {}) {
7
8
  registerRdfTool(server, client);
8
9
  server.registerTool("lbb_inspect", {
9
10
  description: "Read graph context and exact graph facts. Actions: guide, graphs, publication, ontology, ontology_conformance, schema, ontology_search, metadata, entity, state, history, transitions, why. graphs works before bootstrap; publication reports whether writes are queryable. ontology and schema return complete entries with page_size, section and cursor; follow next until absent. schema reads active native ontology/SHACL metadata without running validation. Query asserted RDF/OWL axioms separately with lbb_query. ontology_conformance serves the durable report referenced by the pinned published root. entity returns one node's metadata, scalar attributes, bounded Base-backed edge neighborhood, history, and observations. Use lbb_query with SPARQL property paths for precise path selection.",
@@ -94,7 +95,7 @@ export function registerLbbTools(server, client) {
94
95
  });
95
96
  });
96
97
  server.registerTool("lbb_query", {
97
- description: "Analytical and expert reads. Modes: structured (SPARQL-subset JSON body), sparql (SPARQL text), analyze. SPARQL is the only query surface. 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.",
98
+ description: "Analytical and expert reads. Modes: 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. SPARQL is the query language; search finds what the words describe. 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.",
98
99
  inputSchema: queryWireSchema,
99
100
  annotations: READ_ONLY,
100
101
  }, (rawArgs) => {
@@ -121,6 +122,37 @@ export function registerLbbTools(server, client) {
121
122
  const branch = cursor?.branch ?? args.branch;
122
123
  const offset = cursor?.offset ?? 0;
123
124
  const target = scoped(client, graph, branch);
125
+ const timing = queryTiming(options, {
126
+ mode: args.mode,
127
+ continuation: cursor !== undefined,
128
+ row_limit: rowLimit,
129
+ offset,
130
+ });
131
+ const pin = (requested) => !cursor && requested === undefined
132
+ ? timing.measureAsync("query_pin_metadata", () => queryCommitPin(target, requested, cursor))
133
+ : queryCommitPin(target, requested, cursor);
134
+ const buildEnvelope = (value, rowPage, next, cursorBase, notes) => timing.measure("query_envelope", () => {
135
+ timing.counts({ received_rows: rowPage?.returned });
136
+ const result = queryEnvelope(`lbb_query.${args.mode}`, value, detail, rowPage, next, cursorBase, { textFormat: options.queryTextFormat, notes });
137
+ if (options.timing) {
138
+ const nextCursor = result.next?.cursor;
139
+ timing.counts({
140
+ returned_rows: rowPageFrom(result)?.returned,
141
+ cursor_bytes: typeof nextCursor === "string"
142
+ ? Buffer.byteLength(nextCursor, "utf8")
143
+ : 0,
144
+ });
145
+ }
146
+ return result;
147
+ });
148
+ const render = (value) => timing.measure("query_render", () => {
149
+ const result = queryToolResult(value, options.queryTextFormat);
150
+ if (options.timing)
151
+ timing.counts({
152
+ text_bytes: Buffer.byteLength(result.content[0].text, "utf8"),
153
+ });
154
+ return result;
155
+ });
124
156
  for (const key of [
125
157
  "entailment",
126
158
  "consistency",
@@ -174,7 +206,7 @@ export function registerLbbTools(server, client) {
174
206
  args.as_of_commit_seq !== cursor.as_of_commit_seq) {
175
207
  throw new Error("cursor as_of_commit_seq does not match the supplied as_of_commit_seq argument");
176
208
  }
177
- const asOfCommitSeq = await queryCommitPin(target, requestedCommitSeq, cursor);
209
+ const asOfCommitSeq = await pin(requestedCommitSeq);
178
210
  const request = {
179
211
  ...body,
180
212
  limit: rowLimit,
@@ -189,10 +221,10 @@ export function registerLbbTools(server, client) {
189
221
  request.combinators.length > 0) {
190
222
  throw new Error("`combinators` (UNION/OPTIONAL/MINUS/EXISTS) is no longer accepted by structured mode; the analytics route was removed. Express the same query as SPARQL text with mode=sparql.");
191
223
  }
192
- const response = await target.sparql(request, {
224
+ const response = await timing.measureAsync("query_http_total", () => target.sparql(request, {
193
225
  consistency,
194
226
  minIndexedSeq,
195
- });
227
+ }));
196
228
  const rowPage = rowPageFrom(response);
197
229
  const cursorBase = {
198
230
  v: 1,
@@ -207,7 +239,7 @@ export function registerLbbTools(server, client) {
207
239
  as_of_commit_seq: asOfCommitSeq,
208
240
  };
209
241
  const next = rowPageNext(cursorBase, rowPage);
210
- return toolResult(queryEnvelope(`lbb_query.${args.mode}`, response, detail, rowPage, next, cursorBase));
242
+ return render(buildEnvelope(response, rowPage, next, cursorBase));
211
243
  }
212
244
  // Canonicalize little big brain relation/class/property IRI local-name case up
213
245
  // front, then use the normalized text everywhere (mismatch check,
@@ -229,16 +261,24 @@ export function registerLbbTools(server, client) {
229
261
  args.as_of_commit_seq !== cursor.as_of_commit_seq) {
230
262
  throw new Error("cursor as_of_commit_seq does not match the supplied as_of_commit_seq argument");
231
263
  }
232
- const asOfCommitSeq = await queryCommitPin(target, args.as_of_commit_seq, cursor);
264
+ const asOfCommitSeq = await pin(args.as_of_commit_seq);
233
265
  const entailment = cursor?.entailment ?? args.entailment ?? "none";
234
- const response = await target.sparqlText({
266
+ const response = await timing.measureAsync("query_http_total", () => target.sparqlText({
235
267
  query,
236
268
  entailment,
237
269
  as_of_commit_seq: asOfCommitSeq ?? null,
238
270
  limit: rowLimit,
239
271
  offset,
240
- }, { consistency, minIndexedSeq });
241
- const data = JSON.parse(response.results);
272
+ // Managed evals: the first page records a trace; a
273
+ // continuation page re-reads the same rows and must not.
274
+ ...(cursor === undefined && args.request
275
+ ? { request: args.request }
276
+ : {}),
277
+ }, { consistency, minIndexedSeq }));
278
+ if (response.trace_id) {
279
+ notes.push(`eval trace ${response.trace_id}: to label rows, read their item ids with lbb_evals action=trace trace_id=${response.trace_id}, then call lbb_evals action=label trace_id=${response.trace_id} item=<id> valid=true|false (or items=[…]).`);
280
+ }
281
+ const data = timing.measure("query_results_parse", () => JSON.parse(response.results));
242
282
  const rowPage = rowPageFrom(response);
243
283
  const cursorBase = {
244
284
  v: 1,
@@ -254,14 +294,30 @@ export function registerLbbTools(server, client) {
254
294
  as_of_commit_seq: asOfCommitSeq,
255
295
  };
256
296
  const next = rowPageNext(cursorBase, rowPage);
257
- const sparqlEnvelope = queryEnvelope(`lbb_query.${args.mode}`, data, detail, rowPage, next, cursorBase);
258
- return toolResult(notes.length > 0 ? { ...sparqlEnvelope, notes } : sparqlEnvelope);
297
+ const sparqlEnvelope = buildEnvelope(data, rowPage, next, cursorBase, notes);
298
+ return render(sparqlEnvelope);
259
299
  }
260
300
  catch (error) {
261
301
  return errorResult(await enrichError(client, error));
262
302
  }
263
303
  })();
264
304
  }
305
+ if (args.mode === "search") {
306
+ const searchArgs = args;
307
+ return run(client, "lbb_query.search", undefined, async () => {
308
+ const target = scoped(client, searchArgs.graph, searchArgs.branch);
309
+ return target.embeddings.search({
310
+ embedding: searchArgs.embedding,
311
+ text: searchArgs.text,
312
+ top_k: searchArgs.top_k,
313
+ probe: searchArgs.probe,
314
+ include: searchArgs.include,
315
+ request: searchArgs.request,
316
+ filter: searchArgs.filter,
317
+ explain: searchArgs.explain,
318
+ });
319
+ });
320
+ }
265
321
  return run(client, `lbb_query.${args.mode}`, args.detail, async () => {
266
322
  const target = scoped(client, args.graph, args.branch);
267
323
  return analyze(target, {
@@ -311,6 +367,257 @@ export function registerLbbTools(server, client) {
311
367
  return target.extractorDataset({ limit, splitSeq: split_seq });
312
368
  }
313
369
  }));
370
+ const embeddingSetup = {
371
+ class: z.string().optional().describe("preview/declare: the class IRI."),
372
+ from: z
373
+ .array(z.string())
374
+ .optional()
375
+ .describe('preview/declare: the fields, e.g. ["label", "description", "calls/label"]. Omit for the automatic choice; on an existing embedding, omit to keep its fields.'),
376
+ exclude: z
377
+ .array(z.string())
378
+ .optional()
379
+ .describe("preview/declare: fields to drop from the automatic choice."),
380
+ title: z
381
+ .string()
382
+ .optional()
383
+ .describe('preview/declare: the field that names each hit, e.g. "display_name". Omit for the ontology\'s name property (skos:prefLabel, a property declared rdfs:subPropertyOf rdfs:label, else rdfs:label); on an existing embedding, omit to keep its name. preview reports title_source and, when the labels read as keys, title_suggestion.'),
384
+ model: z
385
+ .string()
386
+ .optional()
387
+ .describe("model: the graph's new model. preview/declare: omit it; a graph has one model (the graph's, or the platform's default for the first embedding)."),
388
+ dim: z.number().int().positive().optional(),
389
+ };
390
+ const recipeOf = (args) => {
391
+ if (!args.class)
392
+ throw new Error("this action requires class");
393
+ return {
394
+ class: args.class,
395
+ ...(args.name ? { name: args.name } : {}),
396
+ ...(args.from ? { from: args.from } : {}),
397
+ ...(args.exclude ? { exclude: args.exclude } : {}),
398
+ ...(args.title ? { title: args.title } : {}),
399
+ ...(args.model ? { model: args.model } : {}),
400
+ ...(args.dim ? { dim: args.dim } : {}),
401
+ };
402
+ };
403
+ server.registerTool("lbb_embeddings", {
404
+ description: "Search setup, read only: embeddings declared on classes (an embedding on a class covers its subclasses; one model per graph). list shows each embedding's status (serving and building version, backfill progress, lag) and the graph's model; get shows one; preview shows every candidate fact of a class with its coverage and examples, and the exact text of sample instances, and stores nothing. Declare, refresh, or change the model with lbb_embeddings_manage; search with lbb_query mode=search. list is the first step of planning a search (lbb_inspect action=guide has the recipe).",
405
+ inputSchema: {
406
+ action: z.enum(["list", "get", "preview"]),
407
+ name: z.string().optional().describe("get: the embedding name."),
408
+ ...embeddingSetup,
409
+ sample: z.number().int().positive().max(50).optional(),
410
+ iris: z
411
+ .array(z.string())
412
+ .optional()
413
+ .describe("preview: the instances to show instead of a sample."),
414
+ detail: detailSchema,
415
+ ...graphScope,
416
+ },
417
+ annotations: READ_ONLY,
418
+ }, (args) => run(client, `lbb_embeddings.${args.action}`, args.detail, () => {
419
+ const target = scoped(client, args.graph, args.branch);
420
+ switch (args.action) {
421
+ case "list":
422
+ return target.embeddings.list();
423
+ case "get":
424
+ if (!args.name)
425
+ throw new Error("get requires name");
426
+ return target.embeddings.get(args.name);
427
+ case "preview":
428
+ return target.embeddings.preview({
429
+ ...recipeOf(args),
430
+ ...(args.sample ? { sample: args.sample } : {}),
431
+ ...(args.iris ? { iris: args.iris } : {}),
432
+ });
433
+ }
434
+ }));
435
+ server.registerTool("lbb_embeddings_manage", {
436
+ description: "Declare or refresh an embedding, or change the graph's model. Run lbb_embeddings action=preview first and show the user the text it will embed. declare sets the class, the fields (one-hop property paths such as label, description, calls/label; omit for an automatic choice); a new embedding takes the graph's model, and another model is refused. On an existing embedding it changes only what you name, and a changed recipe builds a new version while the old one serves. model moves every embedding of the graph to `model`: each builds a new version, and the graph switches when all are ready (a search never mixes two models). refresh runs one step of the embed job now (it also runs by itself after each published commit).",
437
+ inputSchema: {
438
+ action: z.enum(["declare", "refresh", "model"]),
439
+ name: z
440
+ .string()
441
+ .optional()
442
+ .describe("The embedding name (refresh: required)."),
443
+ ...embeddingSetup,
444
+ detail: detailSchema,
445
+ ...graphScope,
446
+ },
447
+ annotations: MUTATING,
448
+ }, (args) => run(client, `lbb_embeddings_manage.${args.action}`, args.detail, () => {
449
+ const target = scoped(client, args.graph, args.branch);
450
+ switch (args.action) {
451
+ case "declare":
452
+ return target.embeddings.declare(recipeOf(args));
453
+ case "refresh":
454
+ if (!args.name)
455
+ throw new Error("refresh requires name");
456
+ return target.embeddings.refresh(args.name);
457
+ case "model":
458
+ if (!args.model)
459
+ throw new Error("model requires model");
460
+ return target.embeddings.setModel({
461
+ model: args.model,
462
+ ...(args.dim ? { dim: args.dim } : {}),
463
+ });
464
+ }
465
+ }));
466
+ server.registerTool("lbb_embeddings_delete", {
467
+ description: "Delete an embedding: its name and its vectors (index-gc removes the stored runs). Its class stops being searchable until it is declared and built again. Ask the user first; confirm must repeat the name.",
468
+ inputSchema: {
469
+ name: z.string().describe("The embedding name."),
470
+ confirm: z.string().describe("The same name again, to confirm."),
471
+ detail: detailSchema,
472
+ ...graphScope,
473
+ },
474
+ annotations: DESTRUCTIVE,
475
+ }, ({ name, confirm, detail, graph, branch }) => run(client, "lbb_embeddings_delete", detail, () => {
476
+ if (confirm !== name) {
477
+ throw new Error("confirm must repeat the embedding name");
478
+ }
479
+ return scoped(client, graph, branch).embeddings.delete(name);
480
+ }));
481
+ server.registerTool("lbb_evals", {
482
+ description: "Managed evals: the thumbs up / thumbs down of the graph, per result. A query run with `request` (lbb_query) records a trace with one item per result (hit or row); trace reads it back with the item ids. label marks one result (item + valid) or several (items) relevant or not; the labels become the golden's ground truth. golden freezes a query (every result it returns now is relevant). run replays every golden at the current commit: pass when every relevant result is back and no wrong one is; new results open a review trace. judge lets the platform's judge model (the hosted frontier model) label unlabeled results. summary, traces, goldens, results, and settings read state.",
483
+ inputSchema: {
484
+ action: z.enum([
485
+ "summary",
486
+ "traces",
487
+ "trace",
488
+ "label",
489
+ "judge",
490
+ "goldens",
491
+ "golden",
492
+ "accept",
493
+ "delete",
494
+ "run",
495
+ "results",
496
+ "settings",
497
+ ]),
498
+ trace_id: z
499
+ .string()
500
+ .optional()
501
+ .describe("trace / label / judge: the trace id from lbb_query."),
502
+ item: z
503
+ .string()
504
+ .optional()
505
+ .describe("label: the result id (a hit's `id`, or an item id from trace) to label with `valid`."),
506
+ valid: z
507
+ .boolean()
508
+ .optional()
509
+ .describe("label: true = the result answers the request (thumbs up), false = it does not."),
510
+ items: z
511
+ .array(z
512
+ .object({
513
+ id: z.string(),
514
+ valid: z.boolean(),
515
+ note: z.string().optional(),
516
+ })
517
+ .strict())
518
+ .optional()
519
+ .describe("label: several results at once."),
520
+ by: z
521
+ .string()
522
+ .optional()
523
+ .describe("label: who labels (an agent name)."),
524
+ note: z.string().optional(),
525
+ sparql: z
526
+ .string()
527
+ .optional()
528
+ .describe("golden: the query to freeze (the search text for surface=search)."),
529
+ surface: z
530
+ .enum(["sparql", "search"])
531
+ .optional()
532
+ .describe("golden: which surface the query runs on (default sparql)."),
533
+ embedding: z
534
+ .string()
535
+ .optional()
536
+ .describe("golden: the embedding, for surface=search."),
537
+ top_k: z
538
+ .number()
539
+ .int()
540
+ .positive()
541
+ .max(200)
542
+ .optional()
543
+ .describe("golden: the top_k of a search golden."),
544
+ request: z
545
+ .string()
546
+ .optional()
547
+ .describe("golden: the user's words the query answers."),
548
+ golden_id: z
549
+ .string()
550
+ .optional()
551
+ .describe("accept / delete: the golden id."),
552
+ limit: z.number().int().positive().optional(),
553
+ unlabeled: z
554
+ .boolean()
555
+ .optional()
556
+ .describe("traces: only traces without a label."),
557
+ consistency: z
558
+ .enum(["strong", "eventual"])
559
+ .optional()
560
+ .describe("run / accept: strong reads the head, eventual the last published commit."),
561
+ detail: detailSchema,
562
+ ...graphScope,
563
+ },
564
+ annotations: MUTATING,
565
+ }, ({ action, trace_id, item, valid, items, by, note, sparql, surface, embedding, top_k, request, golden_id, limit, unlabeled, consistency, detail, graph, branch, }) => run(client, `lbb_evals.${action}`, detail, () => {
566
+ const target = scoped(client, graph, branch);
567
+ switch (action) {
568
+ case "summary":
569
+ return target.evals.summary();
570
+ case "traces":
571
+ return target.evals.traces({ limit, unlabeled });
572
+ case "trace":
573
+ if (!trace_id)
574
+ throw new Error("trace requires trace_id");
575
+ return target.evals.trace(trace_id);
576
+ case "label":
577
+ if (!trace_id)
578
+ throw new Error("label requires trace_id");
579
+ if (!item && !items?.length)
580
+ throw new Error("label requires item (with valid) or items");
581
+ if (item && valid === undefined)
582
+ throw new Error("label requires valid with item");
583
+ return target.evals.label(trace_id, {
584
+ ...(item ? { item, valid } : {}),
585
+ ...(items?.length ? { items } : {}),
586
+ by,
587
+ note,
588
+ });
589
+ case "judge":
590
+ return target.evals.judge({ traceId: trace_id, limit });
591
+ case "goldens":
592
+ return target.evals.goldens();
593
+ case "golden":
594
+ if (!sparql)
595
+ throw new Error("golden requires sparql");
596
+ if (surface === "search" && !embedding)
597
+ throw new Error("a search golden requires embedding");
598
+ return target.evals.createGolden({
599
+ sparql,
600
+ request,
601
+ ...(surface ? { surface } : {}),
602
+ ...(embedding ? { embedding } : {}),
603
+ ...(top_k ? { top_k } : {}),
604
+ });
605
+ case "accept":
606
+ if (!golden_id)
607
+ throw new Error("accept requires golden_id");
608
+ return target.evals.acceptGolden(golden_id, { consistency });
609
+ case "delete":
610
+ if (!golden_id)
611
+ throw new Error("delete requires golden_id");
612
+ return target.evals.deleteGolden(golden_id);
613
+ case "run":
614
+ return target.evals.run({ consistency });
615
+ case "results":
616
+ return target.evals.results({ limit });
617
+ case "settings":
618
+ return target.evals.settings();
619
+ }
620
+ }));
314
621
  server.registerTool("lbb_commit", {
315
622
  description: "Write graph facts, retract them, or label ranked results. mode=facts writes triplets/embeddings/properties; mode=retract removes a wrongly-added fact (by edge or by entity) without a full reset; mode=search_feedback stores query/result relevance labels (Feedback grades: 3=ideal/good, 1=partial, 0=bad; include query, search_id when available, target, rank, score). Explicit idempotency_key wins; when omitted, MCP derives a stable content hash so content-identical retries dedupe. Facts mode defaults edge_idempotency to append; pass skip_unchanged for re-runnable backfills.",
316
623
  inputSchema: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@littlebigbrain/mcp",
3
- "version": "0.5.0",
4
- "description": "MCP server for little big brain — graph and hybrid search tools for agents",
3
+ "version": "0.5.2",
4
+ "description": "MCP server for little big brain: search, graph queries, and data import",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -52,7 +52,7 @@
52
52
  "test:coverage": "npm run clean:test && tsc -p tsconfig.test.json && node --test --experimental-test-coverage --test-coverage-include=test-dist/*.js --test-coverage-exclude=test-dist/*.test.js --test-coverage-exclude=test-dist/stdio.js --test-coverage-exclude=test-dist/test-support.js --test-coverage-lines=90 --test-coverage-branches=70 --test-coverage-functions=85 \"test-dist/**/*.test.js\""
53
53
  },
54
54
  "dependencies": {
55
- "@littlebigbrain/client": "^0.13.1",
55
+ "@littlebigbrain/client": "^0.13.2",
56
56
  "@modelcontextprotocol/sdk": "^1",
57
57
  "zod": "^3"
58
58
  },