@hydradb/mcp 1.1.1 → 1.2.1

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/dist/server.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { createRequire } from "node:module";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { z } from "zod";
4
- import { toAddMemoryResponse, toMemoryList, toRecallResponse, toSourceList } from "./adapters.js";
5
+ import { toAddMemoryResponse, toMemoryList, toSourceList } from "./adapters.js";
5
6
  import { resolveConfig } from "./config.js";
6
- import { buildRecalledContext } from "./context.js";
7
+ import { renderRecalledContext } from "./context.js";
7
8
  import { SERVER_INSTRUCTIONS, TOOL_DESCRIPTIONS } from "./descriptions.js";
8
9
  import { HydraDB } from "./hydra/index.js";
9
10
  import { logger } from "./logger.js";
@@ -20,9 +21,80 @@ const INGEST_INSTRUCTIONS = "Focus on extracting user preferences, habits, opini
20
21
  // both `src/` (tsx) and `dist/` (published build).
21
22
  const require = createRequire(import.meta.url);
22
23
  const { version: SERVER_VERSION } = require("../package.json");
24
+ /**
25
+ * A usable title for an entry the caller did not name.
26
+ *
27
+ * The default was the constant "MCP Memory". Since `title` is the ONLY per-chunk
28
+ * label `buildRecalledContext` renders, fifty untitled saves produced fifty
29
+ * recall results all reading `Source: MCP Memory` — the caller could not cite
30
+ * where a fact came from, or tell whether two chunks were the same memory. It
31
+ * also defeats any future filter on title, since every row shares one value.
32
+ *
33
+ * Deriving from the first line is a safety net, not the fix. The fix is the
34
+ * description telling the model to set one; this keeps the failure from being
35
+ * total when it does not.
36
+ */
37
+ function defaultTitle(text) {
38
+ const firstLine = (text.trim().split("\n", 1)[0] ?? "").trim();
39
+ if (firstLine === "")
40
+ return "Untitled note";
41
+ // Ingested documents commonly start with a markdown heading, and the hashes
42
+ // are noise in a label.
43
+ const cleaned = firstLine.replace(/^#+\s*/, "").trim() || firstLine;
44
+ return cleaned.length <= 60 ? cleaned : `${cleaned.slice(0, 57).trimEnd()}…`;
45
+ }
46
+ /**
47
+ * A source id for a conversation the caller did not name.
48
+ *
49
+ * This was `mcp-conversation-${Date.now()}` — millisecond resolution, no
50
+ * randomness, no process or session identity. Two ingests landing in the same
51
+ * millisecond produced the same id, and because `upsert` is true the second
52
+ * silently REPLACED the first (see the upsert regression test) while reporting
53
+ * "success: 1, failed: 0". Nothing surfaced the loss.
54
+ *
55
+ * The collision window is wider than one agent racing itself: HYDRADB_COLLECTION
56
+ * defaults to the shared literal `hydra-db-mcp`, so every user who does not set
57
+ * it shares one namespace with a low-entropy id.
58
+ *
59
+ * The timestamp prefix is kept because it sorts and reads well; the suffix is
60
+ * what makes it unique.
61
+ */
62
+ function generatedSourceId() {
63
+ return `mcp-conversation-${Date.now()}-${randomUUID().slice(0, 8)}`;
64
+ }
23
65
  function textResult(text) {
24
66
  return { content: [{ type: "text", text }] };
25
67
  }
68
+ /**
69
+ * A result the caller can read OR parse.
70
+ *
71
+ * Every handler returned prose only, so a caller wanting an id had to pull it
72
+ * out of a sentence. `structuredContent` hands over the same facts already
73
+ * parsed — ids it can pass straight to the next tool, counts it can branch on.
74
+ *
75
+ * `content` stays populated alongside it. The MCP spec requires that for hosts
76
+ * that ignore structured output, and dropping it would break every client that
77
+ * renders the text.
78
+ */
79
+ function structuredResult(text, structuredContent) {
80
+ return { content: [{ type: "text", text }], structuredContent };
81
+ }
82
+ /**
83
+ * A failure the caller should treat as a failure, not a result.
84
+ *
85
+ * Three different contracts for "it didn\'t work" used to coexist here: thrown
86
+ * errors became `isError: true`, while a failed inspect and a server-REFUSED
87
+ * delete returned plain text with `isError` absent. A client branching on
88
+ * `isError` therefore read "Could NOT delete X — the server refused" as a
89
+ * success.
90
+ *
91
+ * These stay soft text rather than throws, deliberately — the message is
92
+ * carefully worded and a thrown error would replace it with a generic one — but
93
+ * they are now flagged.
94
+ */
95
+ function errorResult(text) {
96
+ return { content: [{ type: "text", text }], isError: true };
97
+ }
26
98
  /**
27
99
  * What a query actually searched, for the result strings. A query over `all`
28
100
  * must not report "memories" — that phrasing is what taught callers the MCP
@@ -36,10 +108,51 @@ function resultNoun(kind, count) {
36
108
  return one ? "knowledge source" : "knowledge sources";
37
109
  return one ? "context item" : "context items";
38
110
  }
111
+ /**
112
+ * Input ceilings.
113
+ *
114
+ * `text` and `turns` were unbounded. The whole payload is materialised —
115
+ * `JSON.stringify` on the memory path, a Buffer on the knowledge path — so an
116
+ * oversized body is best case a 413 after uploading all of it, worst case an
117
+ * out-of-memory in this process. Rejecting locally is instant, costs no
118
+ * bandwidth, and names the limit.
119
+ *
120
+ * Sized well above any realistic memory or document this tool is asked to store.
121
+ */
122
+ const MAX_TEXT_CHARS = 1000000;
123
+ const MAX_TURNS = 500;
124
+ const MAX_TURN_CHARS = 100000;
39
125
  const turnSchema = z.object({
40
- user: z.string().describe("The user's message"),
41
- assistant: z.string().describe("The assistant's response"),
126
+ user: z
127
+ .string()
128
+ .max(MAX_TURN_CHARS, {
129
+ message: `each turn's user message must be at most ${MAX_TURN_CHARS} characters`,
130
+ })
131
+ .describe("The user's message"),
132
+ assistant: z
133
+ .string()
134
+ .max(MAX_TURN_CHARS, {
135
+ message: `each turn's assistant message must be at most ${MAX_TURN_CHARS} characters`,
136
+ })
137
+ .describe("The assistant's response"),
42
138
  });
139
+ /**
140
+ * `observation_date` is a CALENDAR date. The server answers anything finer with
141
+ * `400 INVALID_INPUT: observation_date "2026-08-17T00:00:00Z" is not a valid
142
+ * ISO-8601 date (want YYYY-MM-DD)`, and a model writing a date in JSON reaches
143
+ * for the date-time form first — so the date-time is accepted here and trimmed
144
+ * to the date the caller wrote, rather than left to fail as a 400 from a remote
145
+ * service after the request has gone out.
146
+ *
147
+ * Trimming is textual on purpose: it keeps the date as written, where converting
148
+ * to UTC first would move "2026-08-17T23:00:00-08:00" to the 18th and silently
149
+ * record a different day than the caller meant. The time of day is the only
150
+ * thing dropped, and it is the part the server has nowhere to store.
151
+ *
152
+ * Anything that is not a date at all still fails, before the network.
153
+ */
154
+ const OBSERVATION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$/;
155
+ const CALENDAR_DATE_LENGTH = "YYYY-MM-DD".length;
43
156
  // Deprecated aliases emit exactly one stderr warning PER PROCESS naming the
44
157
  // canonical replacement (CONTRACT §3). The dedupe state is module-scoped so the
45
158
  // guarantee holds across multiple server instances in the same process, and is
@@ -53,6 +166,74 @@ function warnDeprecatedAlias(name) {
53
166
  const replacement = ALIAS_REPLACEMENTS[name] ?? "a canonical tool";
54
167
  console.error(`[hydradb-mcp] Tool "${name}" is deprecated and will be removed in a future major version; use "${replacement}" instead.`);
55
168
  }
169
+ /**
170
+ * In-flight tool calls, so shutdown can wait for them.
171
+ *
172
+ * `server.close()` tears down the transport; it does not wait for handlers that
173
+ * are already running. Without this, SIGTERM during an ingest kills the process
174
+ * mid-write and the caller never learns whether it committed — which, since a
175
+ * reused source_id replaces, is not a question they can answer by retrying.
176
+ *
177
+ * Module-scoped so it spans every server instance in the process, matching how
178
+ * the alias-warning dedupe is scoped.
179
+ */
180
+ let inFlight = 0;
181
+ const idleWaiters = [];
182
+ /**
183
+ * Set once shutdown begins, so no NEW call is accepted after that point.
184
+ *
185
+ * Draining alone is not enough: a call arriving after the counter reaches zero
186
+ * but before the transport closes would be accepted, then aborted by the close —
187
+ * leaving an ingest caller unable to tell whether the write committed, which is
188
+ * the exact outcome draining exists to prevent.
189
+ */
190
+ let shuttingDown = false;
191
+ /** Stop accepting tool calls. Idempotent. */
192
+ export function beginShutdown() {
193
+ shuttingDown = true;
194
+ }
195
+ /** Test-only: allow a fresh server in the same process after a shutdown test. */
196
+ export function __resetShutdown() {
197
+ shuttingDown = false;
198
+ }
199
+ function trackInFlight(work) {
200
+ if (shuttingDown) {
201
+ return Promise.reject(new Error("Hydra DB MCP server is shutting down and is not accepting new requests. " +
202
+ "Retry once it has restarted."));
203
+ }
204
+ inFlight++;
205
+ return work().finally(() => {
206
+ inFlight--;
207
+ if (inFlight === 0) {
208
+ while (idleWaiters.length > 0)
209
+ idleWaiters.pop()?.();
210
+ }
211
+ });
212
+ }
213
+ /** Resolves once no tool call is running, or immediately if none is. */
214
+ export function awaitInFlight() {
215
+ if (inFlight === 0)
216
+ return Promise.resolve();
217
+ return new Promise((resolve) => idleWaiters.push(resolve));
218
+ }
219
+ /** How many tool calls are currently running. Exported for tests and logging. */
220
+ export function inFlightCount() {
221
+ return inFlight;
222
+ }
223
+ /**
224
+ * Whether the deprecated tool aliases are registered.
225
+ *
226
+ * Off by default as of 1.2.0. Anyone whose mcp.json still calls the old names
227
+ * sets HYDRADB_MCP_LEGACY_TOOLS=1 to restore them — one env var, no code change,
228
+ * and the opt-in is itself the adoption signal that a later removal needs and
229
+ * that nothing here could previously collect.
230
+ */
231
+ export function legacyToolsEnabled(env = process.env) {
232
+ const raw = env.HYDRADB_MCP_LEGACY_TOOLS;
233
+ if (raw == null)
234
+ return false;
235
+ return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
236
+ }
56
237
  /** Test-only: reset the once-per-process alias warning dedupe. */
57
238
  export function __resetAliasWarnings() {
58
239
  warnedAliases.clear();
@@ -75,59 +256,194 @@ export function createHydraDBServer(hydraOverride) {
75
256
  database: config.database,
76
257
  collection: config.collection,
77
258
  ...(config.baseUrl != null ? { baseUrl: config.baseUrl } : {}),
259
+ ...(config.timeoutSeconds != null
260
+ ? { timeoutSeconds: config.timeoutSeconds }
261
+ : {}),
262
+ ...(config.maxRetries != null ? { maxRetries: config.maxRetries } : {}),
78
263
  });
79
264
  logger.info(`Hydra DB connected (database=${config.database}, collection=${config.collection})`);
80
265
  }
81
266
  // --- Handlers (shared by canonical tools and their deprecated aliases) ---
82
- async function runQuery(args) {
267
+ async function runQuery(args, signal) {
83
268
  // Host-owned default (CONTRACT §2 rule 5): search BOTH families. This tool
84
269
  // used to pin `kind: "memory"`, which made every ingested knowledge source
85
270
  // unreachable from the MCP — `hydradb_list`/`hydradb_inspect` could browse
86
271
  // knowledge but nothing could search it.
87
272
  const kind = args.kind ?? "all";
88
273
  logger.debug(`${TOOL_NAMES.QUERY}: "${args.query}" (kind=${kind})`);
274
+ const maxResults = args.max_results ?? 10;
89
275
  const raw = await hydra.context.query({
90
276
  query: args.query,
91
277
  kind,
92
- maxResults: args.max_results ?? 10,
278
+ maxResults,
93
279
  mode: args.mode ?? "thinking",
280
+ operator: args.operator,
281
+ ids: args.source_ids,
282
+ metadataFilters: args.metadata_filters,
283
+ numRelatedChunks: args.num_related_chunks,
94
284
  graphContext: args.graph_context ?? true,
95
- alpha: 0.8,
285
+ // Host-owned default (CONTRACT §2 rule 5), but only where it means
286
+ // something: alpha balances dense against sparse retrieval in HYBRID
287
+ // mode, and an `operator` switches the query to text retrieval (see
288
+ // the wrapper), where there are no two lanes to weigh. Injecting it
289
+ // there would send a hybrid-only knob on a request that is not hybrid.
290
+ alpha: args.operator != null ? undefined : 0.8,
96
291
  recencyBias: 0,
97
- });
98
- const res = toRecallResponse(raw);
292
+ }, { signal });
293
+ // The renderer reads the SDK payload directly; there is no longer a
294
+ // snake_case mirror to convert into.
295
+ const res = raw;
296
+ // The server can return more chunks than were asked for — a live call with
297
+ // max_results=10 came back with 15, and all 15 were rendered. Honour the
298
+ // parameter here so it means what its description says.
299
+ if (res.chunks != null && res.chunks.length > maxResults) {
300
+ res.chunks = res.chunks.slice(0, maxResults);
301
+ }
99
302
  if (!res.chunks || res.chunks.length === 0) {
100
303
  return textResult(`No relevant ${resultNoun(kind)} found in Hydra DB.`);
101
304
  }
102
- const contextStr = buildRecalledContext(res);
103
- const summary = res.chunks.slice(0, 10).map((c, i) => {
104
- const score = c.relevancy_score != null
105
- ? ` (${Math.round(c.relevancy_score * 100)}%)`
106
- : "";
107
- const snippet = c.chunk_content.length > 150
108
- ? `${c.chunk_content.slice(0, 150)}...`
109
- : c.chunk_content;
110
- return `${i + 1}. ${snippet}${score}`;
305
+ // No separate summary block. It listed the first 10 chunks truncated to 150
306
+ // characters each text that is a verbatim prefix of what the context
307
+ // block below already renders in full. Every chunk body went to the caller
308
+ // twice, and the only field the summary carried that the context block did
309
+ // not was the score, which now rides in the chunk header.
310
+ //
311
+ // It also disagreed with its own header: `Found ${length}` counted every
312
+ // chunk while the list stopped at 10, so a 15-chunk result announced 15 and
313
+ // showed 10.
314
+ const compact = (args.detail ?? "compact") === "compact";
315
+ // The header and the legend are part of the response the caller pays for,
316
+ // so the renderer gets a budget with room already reserved for them.
317
+ // Adding framing after the ceiling had been applied put the finished
318
+ // response over the documented limit — the same mistake as leaving the
319
+ // entity-path prefix out of the accounting, one layer up.
320
+ const legend = `\n\n---\nEach [id: …] is a source id: pass one to ${TOOL_NAMES.INSPECT} for that ` +
321
+ `source's full content, or to ${TOOL_NAMES.DELETE} to remove it.`;
322
+ const headerAllowance = 120;
323
+ const { text: contextStr, shown } = renderRecalledContext(res, {
324
+ // Compact keeps every chunk but trims each body and drops the
325
+ // extra-context blocks; `full` is the unchanged rendering.
326
+ ...(compact
327
+ ? { maxChunkChars: COMPACT_CHUNK_CHARS, includeExtraContext: false }
328
+ : {}),
329
+ maxTotalChars: QUERY_CHAR_BUDGET - legend.length - headerAllowance,
111
330
  });
112
- return textResult(`Found ${res.chunks.length} ${resultNoun(kind, res.chunks.length)}:\n\n${summary.join("\n")}\n\n---\nFull context:\n${contextStr}`);
331
+ return textResult(`Found ${shown} ${resultNoun(kind, shown)}:\n\n${contextStr}${legend}`);
332
+ }
333
+ /**
334
+ * The id the server assigned to the item it just stored.
335
+ *
336
+ * On the memory path the caller may supply `source_id`, but when it does not
337
+ * the server assigns one — and that value appeared nowhere in the tool result,
338
+ * so the caller could not later inspect, correct or delete what it had
339
+ * written. Reads the first successful item; ingest here is always one item.
340
+ */
341
+ function createdId(res) {
342
+ for (const item of res.results) {
343
+ if (item.source_id && !item.error)
344
+ return item.source_id;
345
+ }
346
+ return undefined;
113
347
  }
114
- async function runStore(args) {
115
- logger.debug(`${TOOL_NAMES.INGEST}: "${args.text.slice(0, 50)}..."`);
348
+ /**
349
+ * Ingestion is asynchronous, and the caller has no way to know that.
350
+ *
351
+ * The upload returns as soon as the source is queued; indexing then runs
352
+ * through graph extraction and takes seconds. A caller that saves and
353
+ * immediately queries to confirm gets "No relevant context items found" and
354
+ * reasonably concludes the save failed — then re-saves, which under upsert
355
+ * replaces what it just wrote.
356
+ *
357
+ * The server already says this in its 202 body; the adapter kept the message
358
+ * so we can pass the server's own words through rather than invent our own.
359
+ */
360
+ function indexingNote(res) {
361
+ const said = res.message.trim();
362
+ const mentionsAsync = /asynchron|queued|still processing|not.*indexed/i.test(said);
363
+ return (`\n\nIndexing is asynchronous — the content is not searchable until it ` +
364
+ `completes. Use ${TOOL_NAMES.STATUS} to check.` +
365
+ (mentionsAsync ? "" : said ? `\nServer: ${briefly(said)}` : ""));
366
+ }
367
+ /** Keep one server-supplied message from crowding out the rest of the result. */
368
+ function briefly(message) {
369
+ return message.length > 200 ? `${message.slice(0, 200)}…` : message;
370
+ }
371
+ /**
372
+ * Per-item detail for an ingest that did not fully succeed.
373
+ *
374
+ * Two distinct outcomes are worth reporting and neither is visible in the
375
+ * counts alone:
376
+ *
377
+ * - a failed item, where the caller needs the id and the reason to retry
378
+ * just that one rather than re-ingesting everything;
379
+ * - an item the server stored but could not extract relations from, which
380
+ * is a partial success. It is findable by text and unreachable by graph
381
+ * traversal, and `failed_count` stays 0 — so without this line it looks
382
+ * identical to a clean ingest.
383
+ *
384
+ * Returns "" when there is nothing to say, so the success path stays quiet.
385
+ */
386
+ function ingestIssues(res) {
387
+ const lines = [];
388
+ for (const item of res.results) {
389
+ const label = item.source_id || item.title || "(unnamed item)";
390
+ const failure = item.error ?? (item.status === "failed" ? "ingestion failed" : null);
391
+ if (failure) {
392
+ const code = item.error_code ? ` [${item.error_code}]` : "";
393
+ lines.push(` - ${label}: ${briefly(failure)}${code}`);
394
+ }
395
+ else if (item.relations_error) {
396
+ lines.push(` - ${label}: stored, but graph extraction failed — ${briefly(item.relations_error)}. ` +
397
+ `It is searchable by text but will not be reached by graph traversal.`);
398
+ }
399
+ }
400
+ return lines.length > 0 ? `\n\nIssues:\n${lines.join("\n")}` : "";
401
+ }
402
+ async function runStore(args, signal) {
403
+ const kind = args.kind ?? "memory";
404
+ logger.debug(`${TOOL_NAMES.INGEST}: "${args.text.slice(0, 50)}..." (kind=${kind})`);
405
+ // The memory item shape has no counterpart on the knowledge path, which
406
+ // carries only a document and its filename — so those fields are sent only
407
+ // where they mean something. The wrapper rejects them on the knowledge
408
+ // branch rather than dropping them, and passing them here unconditionally
409
+ // would make every knowledge write fail.
410
+ const memoryOnly = kind === "memory"
411
+ ? {
412
+ sourceId: args.source_id,
413
+ infer: args.infer ?? true,
414
+ isMarkdown: args.is_markdown ?? false,
415
+ customInstructions: INGEST_INSTRUCTIONS,
416
+ metadata: args.metadata,
417
+ observationDate: args.observation_date,
418
+ }
419
+ : {};
116
420
  const raw = await hydra.context.ingest({
117
- kind: "memory",
421
+ kind,
118
422
  text: args.text,
119
- title: args.title ?? "MCP Memory",
120
- sourceId: args.source_id,
121
- infer: args.infer ?? true,
122
- isMarkdown: args.is_markdown ?? false,
123
- customInstructions: INGEST_INSTRUCTIONS,
124
- upsert: true,
125
- });
423
+ title: args.title ?? defaultTitle(args.text),
424
+ ...memoryOnly,
425
+ // Default stays true. The SDK retries POSTs, so upsert is what keeps a
426
+ // retried ingest from duplicating — flipping this default would trade a
427
+ // silent overwrite for a silent duplicate.
428
+ upsert: args.overwrite ?? true,
429
+ }, { signal });
126
430
  const res = toAddMemoryResponse(raw);
127
- const preview = args.text.length > 80 ? `${args.text.slice(0, 80)}...` : args.text;
128
- return textResult(`Saved to Hydra DB (${res.success_count} success, ${res.failed_count} failed): "${preview}"`);
431
+ // Was an 80-char echo of the text the caller had just sent — zero
432
+ // information back to them. The id is the thing they do not have and
433
+ // cannot derive, and it is what makes correcting this memory later
434
+ // possible at all.
435
+ const id = createdId(res) ?? args.source_id;
436
+ return structuredResult(`Saved to Hydra DB${id ? ` (id: ${id})` : ""} ` +
437
+ `(${res.success_count} success, ${res.failed_count} failed).` +
438
+ indexingNote(res) +
439
+ ingestIssues(res), {
440
+ ...(id != null ? { id } : {}),
441
+ success_count: res.success_count,
442
+ failed_count: res.failed_count,
443
+ indexing_pending: true,
444
+ });
129
445
  }
130
- async function runIngestConversation(turns, sourceId, opts) {
446
+ async function runIngestConversation(turns, sourceId, opts, signal) {
131
447
  logger.debug(`${TOOL_NAMES.INGEST}: ${turns.length} turns -> ${sourceId}`);
132
448
  const raw = await hydra.context.ingest({
133
449
  kind: "memory",
@@ -138,50 +454,297 @@ export function createHydraDBServer(hydraOverride) {
138
454
  title: opts?.title,
139
455
  isMarkdown: opts?.isMarkdown,
140
456
  customInstructions: INGEST_INSTRUCTIONS,
141
- upsert: true,
142
- });
457
+ upsert: opts?.overwrite ?? true,
458
+ }, { signal });
143
459
  const res = toAddMemoryResponse(raw);
144
- return textResult(`Ingested ${turns.length} conversation turn(s) into Hydra DB (source: ${sourceId}, success: ${res.success_count}, failed: ${res.failed_count})`);
460
+ const conversationId = createdId(res) ?? sourceId;
461
+ return structuredResult(`Ingested ${turns.length} conversation turn(s) into Hydra DB ` +
462
+ `(id: ${conversationId}, success: ${res.success_count}, failed: ${res.failed_count})` +
463
+ indexingNote(res) +
464
+ ingestIssues(res), {
465
+ id: conversationId,
466
+ success_count: res.success_count,
467
+ failed_count: res.failed_count,
468
+ indexing_pending: true,
469
+ });
145
470
  }
146
- async function runListMemories() {
471
+ /**
472
+ * How much of the corpus this page covered, stated plainly.
473
+ *
474
+ * A listing that shows 50 of 4,000 rows and says "50 memories:" is not a
475
+ * truncated answer, it is a wrong one — the caller reports it as the complete
476
+ * inventory. Say what was shown, out of what, and how to reach the rest.
477
+ */
478
+ /**
479
+ * Whether another page exists.
480
+ *
481
+ * `total > shown` is NOT a usable test on its own: on the last page of a large
482
+ * corpus it is still true (12 shown of 412) and would point the caller at a
483
+ * page that does not exist. Prefer what the server stated, then the page
484
+ * arithmetic, and only then the row comparison — which is correct on page 1,
485
+ * the only place it is reached.
486
+ */
487
+ function hasMore(shown, page, requestedPage) {
488
+ const total = page.total ?? shown;
489
+ const current = page.page ?? requestedPage ?? 1;
490
+ const seen = (current - 1) * (page.page_size ?? shown) + shown;
491
+ return (page.has_next ??
492
+ (page.total_pages != null ? current < page.total_pages : seen < total));
493
+ }
494
+ function coverage(shown, page, requestedPage) {
495
+ const total = page.total ?? shown;
496
+ const current = page.page ?? requestedPage ?? 1;
497
+ const more = hasMore(shown, page, requestedPage);
498
+ if (!more && current === 1)
499
+ return `${shown}`;
500
+ return `${shown} of ${total} (page ${current})${more ? ` — pass page=${current + 1} for more` : ""}`;
501
+ }
502
+ async function runListMemories(args = {}, signal) {
147
503
  logger.debug(TOOL_NAMES.LIST);
148
- const raw = await hydra.context.list({ kind: "memory" });
149
- const memories = toMemoryList(raw);
504
+ const raw = await hydra.context.list({
505
+ kind: "memory",
506
+ ids: args.source_ids,
507
+ page: args.page,
508
+ pageSize: args.page_size,
509
+ }, { signal });
510
+ const { memories, page } = toMemoryList(raw);
150
511
  if (memories.length === 0) {
151
- return textResult("No memories stored yet.");
512
+ // Declaring an outputSchema obliges EVERY return path to carry structured
513
+ // content, including this one — a caller branching on `items` should not
514
+ // have to special-case the empty result.
515
+ return structuredResult(args.page != null && args.page > 1
516
+ ? `No memories on page ${args.page}.`
517
+ : "No memories stored yet.", {
518
+ kind: "memory",
519
+ items: [],
520
+ shown: 0,
521
+ total: page.total ?? 0,
522
+ page: args.page ?? 1,
523
+ has_more: false,
524
+ });
152
525
  }
153
- const lines = memories.map((m, i) => `${i + 1}. [${m.memory_id}] ${m.memory_content.slice(0, 150)}`);
154
- return textResult(`${memories.length} memories:\n\n${lines.join("\n")}`);
526
+ const lines = memories.map((m, i) => {
527
+ // The query path appends "..." when it truncates; this one did not, so a
528
+ // half sentence read as a complete fact.
529
+ const content = m.memory_content;
530
+ const snippet = content.length > 150 ? `${content.slice(0, 150)}...` : content;
531
+ return `${i + 1}. [${m.memory_id}] ${snippet}`;
532
+ });
533
+ return structuredResult(`${coverage(memories.length, page, args.page)} memories:\n\n${lines.join("\n")}`, {
534
+ kind: "memory",
535
+ // Bounded like the text preview. The structured payload previously
536
+ // carried every memory_content in full, so a host consuming it got
537
+ // megabytes from a routine inventory call while the prose beside it
538
+ // showed 150 characters per row. Structured output is a different
539
+ // encoding of the same answer, not a bypass of its limits.
540
+ items: memories.map((m) => ({
541
+ id: m.memory_id,
542
+ content: clampPreview(m.memory_content),
543
+ })),
544
+ shown: memories.length,
545
+ total: page.total ?? memories.length,
546
+ page: page.page ?? args.page ?? 1,
547
+ has_more: hasMore(memories.length, page, args.page),
548
+ });
155
549
  }
156
- async function runListSources(args) {
550
+ async function runListSources(args, signal) {
157
551
  logger.debug(TOOL_NAMES.LIST);
158
552
  const raw = await hydra.context.list({
159
553
  kind: "knowledge",
160
554
  ids: args.source_ids,
161
- });
162
- const { sources, total } = toSourceList(raw);
555
+ page: args.page,
556
+ pageSize: args.page_size,
557
+ }, { signal });
558
+ const { sources, page } = toSourceList(raw);
163
559
  if (sources.length === 0) {
164
- return textResult("No sources found.");
560
+ return structuredResult(args.page != null && args.page > 1
561
+ ? `No sources on page ${args.page}.`
562
+ : "No sources found.", {
563
+ kind: "knowledge",
564
+ items: [],
565
+ shown: 0,
566
+ total: page.total ?? 0,
567
+ page: args.page ?? 1,
568
+ has_more: false,
569
+ });
165
570
  }
166
571
  const lines = sources.map((s, i) => {
167
572
  const title = s.title ? ` — ${s.title}` : "";
168
573
  const type = s.type ? ` (${s.type})` : "";
169
574
  return `${i + 1}. [${s.id}]${title}${type}`;
170
575
  });
171
- return textResult(`${total} sources:\n\n${lines.join("\n")}`);
576
+ // Was `${total} sources:` — the corpus-wide total printed above a single
577
+ // page of rows, so "412 sources:" sat over 50 lines with no marker and no
578
+ // way to reach the other 362.
579
+ return structuredResult(`${coverage(sources.length, page, args.page)} sources:\n\n${lines.join("\n")}`, {
580
+ kind: "knowledge",
581
+ items: sources.map((src) => ({
582
+ id: src.id,
583
+ ...(src.title != null ? { title: src.title } : {}),
584
+ ...(src.type != null ? { type: src.type } : {}),
585
+ })),
586
+ shown: sources.length,
587
+ total: page.total ?? sources.length,
588
+ page: page.page ?? args.page ?? 1,
589
+ has_more: hasMore(sources.length, page, args.page),
590
+ });
591
+ }
592
+ /**
593
+ * How much source text one inspect call may put into the caller's context.
594
+ *
595
+ * Roughly 5k tokens. Large enough that ordinary documents come back whole,
596
+ * small enough that no single call can dominate a conversation.
597
+ */
598
+ const INSPECT_CHAR_BUDGET = 20000;
599
+ /**
600
+ * The per-row preview length shared by the text and structured listings.
601
+ *
602
+ * They must agree: a caller reading `structuredContent` and a caller reading
603
+ * the prose should get the same answer, not two different ones.
604
+ */
605
+ const LIST_PREVIEW_CHARS = 150;
606
+ function clampPreview(text) {
607
+ return text.length > LIST_PREVIEW_CHARS
608
+ ? `${text.slice(0, LIST_PREVIEW_CHARS)}...`
609
+ : text;
610
+ }
611
+ /**
612
+ * Query output ceilings.
613
+ *
614
+ * Chunk bodies were rendered at full length with no cap of any kind, so one
615
+ * query over a corpus of long documents could dominate the caller's context.
616
+ * `compact` trims each body and drops the extra-context blocks; `full`
617
+ * restores the previous rendering. The total budget applies either way,
618
+ * because fifty capped chunks still add up.
619
+ */
620
+ const COMPACT_CHUNK_CHARS = 600;
621
+ const QUERY_CHAR_BUDGET = 40000;
622
+ /** Bound any one server-supplied string, marking it when it is shortened. */
623
+ function clamp(text, budget) {
624
+ if (text.length <= budget)
625
+ return text;
626
+ return `${text.slice(0, budget)}\n\n[truncated: ${text.length} chars total]`;
172
627
  }
173
- async function runInspect(args) {
628
+ /**
629
+ * The readable part of an inspect response, bounded.
630
+ *
631
+ * This was `res.content ?? res.contentBase64 ?? "(no text content)"`, with no
632
+ * cap anywhere between the API and the tool result. Two problems:
633
+ *
634
+ * - unbounded text. A large ingested document arrives whole, and the caller
635
+ * cannot un-read it or tell in advance how big it is. The tool is
636
+ * annotated readOnlyHint, so clients call it speculatively.
637
+ * - base64. `contentBase64` is the binary fallback and base64 inflates 4/3,
638
+ * so a 1 MB scanned PDF becomes ~1.4M characters — a whole context window
639
+ * in one call. It only fires when text extraction yielded nothing, which
640
+ * is precisely the case a user retries by hand when the first call looks
641
+ * empty.
642
+ *
643
+ * Binary is never inlined. The caller is told what it is, how big, and how to
644
+ * get it — `mode: "url"` already returns a download link.
645
+ */
646
+ function inspectBody(res, offset, limit) {
647
+ if (res.content == null || res.content === "") {
648
+ if (res.contentBase64) {
649
+ const size = res.sizeBytes != null ? `${res.sizeBytes} bytes` : "unknown size";
650
+ // The summary is server-generated and unbounded, so it has to obey
651
+ // the same budget as the content it stands in for — otherwise the
652
+ // binary branch, which exists to keep this response small, becomes
653
+ // its own way of blowing past it.
654
+ const summary = res.inferredContent
655
+ ? `\n\nSummary of the content:\n${clamp(res.inferredContent, INSPECT_CHAR_BUDGET)}`
656
+ : "";
657
+ return (`(binary ${res.contentType ?? "content"}, ${size} — not shown. ` +
658
+ `Call again with mode:"url" for a download link.)${summary}`);
659
+ }
660
+ return "(no text content)";
661
+ }
662
+ const start = Math.max(0, offset ?? 0);
663
+ const budget = Math.min(limit ?? INSPECT_CHAR_BUDGET, INSPECT_CHAR_BUDGET);
664
+ const total = res.content.length;
665
+ if (start === 0 && total <= budget)
666
+ return res.content;
667
+ const slice = res.content.slice(start, start + budget);
668
+ const end = start + slice.length;
669
+ const more = end < total
670
+ ? ` Call again with offset=${end} for the next ${Math.min(budget, total - end)}.`
671
+ : "";
672
+ return (`${slice}\n\n[truncated: showing characters ${start}-${end} of ${total}.${more}]`);
673
+ }
674
+ /** Accept either spelling, and say so when neither is present. */
675
+ function toInspectArgs(args) {
676
+ const a = args;
677
+ // Reject a conflict rather than picking one. This server rejects `text`
678
+ // AND `turns` on ingest for the same reason: silently choosing between two
679
+ // values the caller deliberately supplied means acting on a target they
680
+ // did not ask for, and here that target can be a DELETE.
681
+ if (a.id != null && a.source_id != null && a.id !== a.source_id) {
682
+ throw new Error(`${TOOL_NAMES.INSPECT} received different values for \`id\` (${a.id}) and its ` +
683
+ `deprecated alias \`source_id\` (${a.source_id}). Pass only \`id\`.`);
684
+ }
685
+ const id = a.id ?? a.source_id;
686
+ if (!id) {
687
+ throw new Error(`${TOOL_NAMES.INSPECT} requires \`id\` — the value shown as [id: …] in ` +
688
+ `${TOOL_NAMES.QUERY} results or in [brackets] in ${TOOL_NAMES.LIST} output.`);
689
+ }
690
+ return {
691
+ source_id: id,
692
+ mode: a.mode,
693
+ offset: a.offset,
694
+ limit: a.limit,
695
+ expiry_seconds: a.expiry_seconds,
696
+ };
697
+ }
698
+ async function runInspect(args, signal) {
174
699
  logger.debug(`${TOOL_NAMES.INSPECT}: ${args.source_id}`);
175
700
  const res = await hydra.context.inspect({
176
701
  id: args.source_id,
177
702
  mode: args.mode ?? "content",
178
- });
703
+ expirySeconds: args.expiry_seconds,
704
+ }, { signal });
179
705
  // Soft failure: return a normal (non-error) text result, matching v1.
180
706
  if (!res.success || res.error) {
181
- return textResult(`Could not fetch source ${args.source_id}: ${res.error ?? "unknown error"}`);
707
+ return errorResult(`Could not fetch source ${args.source_id}: ${res.error ?? "unknown error"}`);
708
+ }
709
+ const mode = args.mode ?? "content";
710
+ const parts = [`Source: ${args.source_id}`];
711
+ // `presignedUrl` was never read, so `mode: "url"` — documented in the
712
+ // schema and the README — returned "(no text content)" and nothing else.
713
+ // The one mode whose entire purpose is the link never emitted the link.
714
+ if (mode === "url" || mode === "both") {
715
+ parts.push(res.presignedUrl
716
+ ? `Download URL (time-limited): ${res.presignedUrl}`
717
+ : "No download URL available for this source.");
718
+ }
719
+ if (mode === "content" || mode === "both") {
720
+ parts.push(inspectBody(res, args.offset, args.limit));
182
721
  }
183
- const content = res.content ?? res.contentBase64 ?? "(no text content)";
184
- return textResult(`Source: ${args.source_id}\n\n${content}`);
722
+ return textResult(parts.join("\n\n"));
723
+ }
724
+ async function runStatus(args, signal) {
725
+ logger.debug(`${TOOL_NAMES.STATUS}: ${args.ids.join(", ")}`);
726
+ const res = await hydra.context.ingestionStatus({ ids: args.ids }, { signal });
727
+ const statuses = res.statuses ?? [];
728
+ if (statuses.length === 0) {
729
+ return textResult(`No indexing status found for: ${args.ids.join(", ")}. ` +
730
+ `Either the ids are wrong or the sources were never queued.`);
731
+ }
732
+ const lines = statuses.map((s) => {
733
+ const state = s.indexingStatus ?? "unknown";
734
+ const reason = s.errorMessage
735
+ ? ` — ${briefly(s.errorMessage)}${s.errorCode ? ` [${s.errorCode}]` : ""}`
736
+ : "";
737
+ return ` - ${s.id ?? "(unknown id)"}: ${state}${reason}`;
738
+ });
739
+ // `completed` and `failed` are terminal; everything else means keep
740
+ // waiting. Deliberately not switched over the SDK's status enum
741
+ // (queued/processing/completed/failed) — a live run returned
742
+ // `graph_creation`, which that enum does not declare.
743
+ const pending = statuses.filter((s) => !["completed", "failed"].includes(String(s.indexingStatus).toLowerCase()));
744
+ const note = pending.length > 0
745
+ ? `\n\n${pending.length} still indexing — not yet searchable. Check again in a few seconds.`
746
+ : `\n\nAll sources have reached a terminal state.`;
747
+ return textResult(`Indexing status:\n${lines.join("\n")}${note}`);
185
748
  }
186
749
  /**
187
750
  * Three outcomes, not two. A delete that removed nothing is either the
@@ -191,19 +754,65 @@ export function createHydraDBServer(hydraOverride) {
191
754
  * observe, and it is the reassuring one: the caller is told their data is
192
755
  * gone when the server just declined to remove it.
193
756
  */
194
- function deleteReport(kind, id, res, removed) {
757
+ function deleteReport(kind, ids, res, removed,
758
+ /** How many were removed, when the server said. `undefined` means unknown. */
759
+ removedCount) {
195
760
  const noun = kind === "knowledge" ? "source" : "memory";
761
+ const id = ids.join(", ");
196
762
  if (removed) {
197
- return textResult(`Deleted ${noun}: ${id}`);
763
+ // Three outcomes, and the third is "we were not told".
764
+ const partial = removedCount != null && ids.length > 1 && removedCount < ids.length;
765
+ const unknownCount = removedCount == null && ids.length > 1;
766
+ let text;
767
+ if (partial) {
768
+ text =
769
+ `Deleted ${removedCount} of ${ids.length} ${noun}s (requested: ${id}). ` +
770
+ `The rest were not found or could not be removed.`;
771
+ }
772
+ else if (unknownCount) {
773
+ // Do not claim all of them went. The server confirmed a removal and
774
+ // gave no count, so that is exactly what gets reported.
775
+ text =
776
+ `Deleted from Hydra DB (requested: ${id}). The server confirmed a removal ` +
777
+ `but did not say how many of the ${ids.length} ids were removed — ` +
778
+ `use ${TOOL_NAMES.LIST} to confirm which remain.`;
779
+ }
780
+ else {
781
+ text = `Deleted ${noun}${ids.length > 1 ? "s" : ""}: ${id}`;
782
+ }
783
+ return structuredResult(text, {
784
+ ids,
785
+ kind,
786
+ deleted: true,
787
+ // Omitted rather than guessed when the server did not report it.
788
+ ...(removedCount != null ? { deleted_count: removedCount } : {}),
789
+ ...(partial ? { partial: true } : {}),
790
+ ...(unknownCount ? { deleted_count_known: false } : {}),
791
+ });
198
792
  }
199
793
  if (res.success === false) {
200
794
  const reason = deleteFailureReason(res);
201
- return textResult(`Could NOT delete ${noun} ${id} — the server refused the request` +
202
- `${reason ? `: ${reason}` : " and gave no reason"}. ` +
203
- `The ${noun} has not been removed.`);
795
+ return {
796
+ ...structuredResult(`Could NOT delete ${noun} ${id} the server refused the request` +
797
+ `${reason ? `: ${reason}` : " and gave no reason"}. ` +
798
+ `The ${noun} has not been removed.`, {
799
+ ids,
800
+ kind,
801
+ deleted: false,
802
+ deleted_count: 0,
803
+ ...(reason ? { reason } : {}),
804
+ }),
805
+ isError: true,
806
+ };
204
807
  }
205
- const Noun = noun.charAt(0).toUpperCase() + noun.slice(1);
206
- return textResult(`${Noun} ${id} was not found or already deleted.`);
808
+ // The server succeeded and removed nothing, so no such id exists in this
809
+ // database. "or already deleted" was the same mistake the refusal branch
810
+ // above was written to fix: it offers a cause we did not observe, and the
811
+ // reassuring one. A caller that invented an id — the likely case, since
812
+ // until recently nothing emitted one — reads it as confirmation and tells
813
+ // the user their data is gone.
814
+ return structuredResult(`No ${noun} with id ${id} exists in this database — nothing was deleted. ` +
815
+ `Ids come from ${TOOL_NAMES.QUERY} or ${TOOL_NAMES.LIST}; check the id rather than retrying.`, { ids, kind, deleted: false, deleted_count: 0, reason: "not found" });
207
816
  }
208
817
  /** The server's own explanation, preferring the per-item error over the summary. */
209
818
  function deleteFailureReason(res) {
@@ -217,30 +826,61 @@ export function createHydraDBServer(hydraOverride) {
217
826
  }
218
827
  return res.message !== "" ? res.message : undefined;
219
828
  }
220
- async function runDelete(args) {
829
+ /** Accept `ids` or the singular `id`, and say where a real id comes from. */
830
+ function toDeleteArgs(args) {
831
+ const a = args;
832
+ const ids = a.ids ?? (a.id != null ? [a.id] : []);
833
+ if (ids.length === 0) {
834
+ throw new Error(`${TOOL_NAMES.DELETE} requires \`ids\` (or \`id\`). Ids come from ` +
835
+ `${TOOL_NAMES.QUERY} or ${TOOL_NAMES.LIST} — do not guess one.`);
836
+ }
837
+ return { ids, kind: a.kind };
838
+ }
839
+ async function runDelete(args, signal) {
221
840
  const kind = args.kind ?? "memory";
222
- logger.debug(`${TOOL_NAMES.DELETE}: ${kind} ${args.id}`);
223
- const res = await hydra.context.delete({ ids: [args.id], kind });
224
- const removed = (res.userMemoryDeleted ?? 0) > 0 || (res.deletedCount ?? 0) > 0;
841
+ logger.debug(`${TOOL_NAMES.DELETE}: ${kind} ${args.ids.join(", ")}`);
842
+ const res = await hydra.context.delete({ ids: args.ids, kind }, { signal });
843
+ // `userMemoryDeleted` is a COUNT on the v2 wire a live delete returned
844
+ // `{"deletedCount":1,"userMemoryDeleted":1}` — and the SDK types it as a
845
+ // number. The v1 memory-delete handler returns a boolean for the same
846
+ // concept, so both are handled: a boolean answers "did anything go?" and
847
+ // only a number answers "how many?".
848
+ //
849
+ // The distinction matters for bulk delete. Reading a bare `true` as 1
850
+ // would report a successful 3-id removal as "Deleted 1 of 3 … partial",
851
+ // inventing a failure that did not happen — the mirror image of claiming
852
+ // success over a genuine partial.
853
+ const rawMemoryDeleted = res.userMemoryDeleted;
854
+ const memoryDeletedCount = typeof rawMemoryDeleted === "number" ? rawMemoryDeleted : undefined;
855
+ const reportedCount = res.deletedCount ?? memoryDeletedCount;
856
+ const removed = (reportedCount ?? 0) > 0 || rawMemoryDeleted === true;
857
+ // With no count at all, we do not know how many went — and inventing one
858
+ // is wrong in both directions. Reading the flag as 1 understated a full
859
+ // removal ("Deleted 1 of 3"); substituting ids.length overstates a partial
860
+ // one as complete success. So the count stays UNKNOWN and the report says
861
+ // so, which is the only thing actually observed.
862
+ const removedCount = reportedCount;
225
863
  if (!removed) {
226
- logger.warn(`${TOOL_NAMES.DELETE}: removed nothing for ${kind} ${args.id}`, { success: res.success, message: res.message, results: res.results });
864
+ logger.warn(`${TOOL_NAMES.DELETE}: removed nothing for ${kind} ${args.ids.join(", ")}`, { success: res.success, message: res.message, results: res.results });
227
865
  }
228
- return deleteReport(kind, args.id, res, removed);
866
+ return deleteReport(kind, args.ids, res, removed, removedCount);
229
867
  }
230
868
  // --- Registration helper ---
231
- function register(name, inputSchema, handler, annotations) {
869
+ function register(name, inputSchema, handler, annotations, outputSchema) {
232
870
  const desc = TOOL_DESCRIPTIONS[name];
233
871
  const isDeprecated = DEPRECATED_TOOL_NAMES.includes(name);
872
+ const counted = (args, extra) => trackInFlight(() => handler(args, extra));
234
873
  const wrapped = isDeprecated
235
- ? (args) => {
874
+ ? (args, extra) => {
236
875
  warnDeprecatedAlias(name);
237
- return handler(args);
876
+ return counted(args, extra);
238
877
  }
239
- : handler;
878
+ : counted;
240
879
  server.registerTool(name, {
241
880
  title: desc.title,
242
881
  description: desc.description,
243
882
  inputSchema: inputSchema,
883
+ ...(outputSchema ? { outputSchema: outputSchema } : {}),
244
884
  ...(annotations ? { annotations } : {}),
245
885
  }, wrapped);
246
886
  }
@@ -258,16 +898,45 @@ export function createHydraDBServer(hydraOverride) {
258
898
  .optional()
259
899
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.max_results),
260
900
  mode: z
261
- .enum(["fast", "thinking"])
901
+ .enum(["fast", "thinking", "auto"])
262
902
  .optional()
263
903
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.mode),
264
904
  graph_context: z
265
905
  .boolean()
266
906
  .optional()
267
907
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.graph_context),
908
+ detail: z
909
+ .enum(["compact", "full"])
910
+ .optional()
911
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.detail),
912
+ operator: z
913
+ .enum(["or", "and", "phrase"])
914
+ .optional()
915
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.operator),
916
+ source_ids: z
917
+ .array(z.string())
918
+ .min(1)
919
+ .optional()
920
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.source_ids),
921
+ metadata_filters: z
922
+ .record(z.unknown())
923
+ .optional()
924
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.metadata_filters),
925
+ num_related_chunks: z
926
+ .number()
927
+ .int()
928
+ .min(0)
929
+ .max(5)
930
+ .optional()
931
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.num_related_chunks),
268
932
  };
269
933
  const storeSchema = {
270
- text: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.text),
934
+ text: z
935
+ .string()
936
+ .max(MAX_TEXT_CHARS, {
937
+ message: `text must be at most ${MAX_TEXT_CHARS} characters`,
938
+ })
939
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.text),
271
940
  title: z
272
941
  .string()
273
942
  .optional()
@@ -284,20 +953,53 @@ export function createHydraDBServer(hydraOverride) {
284
953
  .boolean()
285
954
  .optional()
286
955
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.is_markdown),
956
+ overwrite: z
957
+ .boolean()
958
+ .optional()
959
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.overwrite),
960
+ };
961
+ const ingestMetadataSchema = {
962
+ metadata: z
963
+ .record(z.unknown())
964
+ .optional()
965
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.metadata),
966
+ observation_date: z
967
+ .string()
968
+ .regex(OBSERVATION_DATE_PATTERN, {
969
+ message: "observation_date must be a calendar date as YYYY-MM-DD (e.g. 2026-07-04); " +
970
+ "a date-time is accepted and kept as its date part",
971
+ })
972
+ .transform((value) => value.slice(0, CALENDAR_DATE_LENGTH))
973
+ .optional()
974
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.observation_date),
287
975
  };
288
976
  const ingestSchema = {
289
977
  ...storeSchema,
978
+ ...ingestMetadataSchema,
290
979
  // Canonical ingest accepts EITHER `text` or `turns`, so `text` is optional
291
980
  // here (the `hydra_db_store` alias keeps it required).
292
981
  text: z
293
982
  .string()
983
+ .max(MAX_TEXT_CHARS, {
984
+ message: `text must be at most ${MAX_TEXT_CHARS} characters`,
985
+ })
294
986
  .optional()
295
987
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.text),
988
+ kind: z
989
+ .enum(["memory", "knowledge"])
990
+ .optional()
991
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.kind),
296
992
  turns: z
297
993
  .array(turnSchema)
298
994
  .min(1)
995
+ .max(MAX_TURNS, { message: `at most ${MAX_TURNS} turns per ingest` })
299
996
  .optional()
300
997
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.turns),
998
+ // `text` and `turns` are mutually exclusive and exactly one is required.
999
+ // JSON Schema cannot express that, so the rule lives in three places: the
1000
+ // tool description, these two param descriptions, and the handler check
1001
+ // below. They must agree — a model that reads "provide turns rather than
1002
+ // text" concludes both are allowed and discovers otherwise at runtime.
301
1003
  user_name: z
302
1004
  .string()
303
1005
  .optional()
@@ -307,6 +1009,7 @@ export function createHydraDBServer(hydraOverride) {
307
1009
  turns: z
308
1010
  .array(turnSchema)
309
1011
  .min(1)
1012
+ .max(MAX_TURNS, { message: `at most ${MAX_TURNS} turns per ingest` })
310
1013
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.turns),
311
1014
  source_id: z
312
1015
  .string()
@@ -317,14 +1020,34 @@ export function createHydraDBServer(hydraOverride) {
317
1020
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.user_name),
318
1021
  };
319
1022
  const listSchema = {
1023
+ // Required, not defaulted. `hydradb_list({})` used to return memories only
1024
+ // and read as the complete inventory, so a caller asking "what does Hydra
1025
+ // DB have?" never saw the knowledge corpus — which hydradb_query searches
1026
+ // by default. Same class of bug as the query `kind` pin, on the list path.
320
1027
  kind: z
321
1028
  .enum(["memory", "knowledge"])
322
- .optional()
323
1029
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.kind),
324
- source_ids: z
1030
+ ids: z
325
1031
  .array(z.string())
326
1032
  .optional()
327
1033
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.source_ids),
1034
+ source_ids: z
1035
+ .array(z.string())
1036
+ .optional()
1037
+ .describe("Deprecated alias for `ids`."),
1038
+ page: z
1039
+ .number()
1040
+ .int()
1041
+ .min(1)
1042
+ .optional()
1043
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.page),
1044
+ page_size: z
1045
+ .number()
1046
+ .int()
1047
+ .min(1)
1048
+ .max(100)
1049
+ .optional()
1050
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.page_size),
328
1051
  };
329
1052
  const listSourcesSchema = {
330
1053
  source_ids: z
@@ -333,44 +1056,170 @@ export function createHydraDBServer(hydraOverride) {
333
1056
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES].params.source_ids),
334
1057
  };
335
1058
  const inspectSchema = {
336
- source_id: z
1059
+ // CONTRACT §1 says a source's identifier field is `id`, but this surface
1060
+ // spelled one concept three ways across tools meant to chain: inspect took
1061
+ // `source_id`, list took `source_ids`, delete took `id`. `id` is canonical
1062
+ // here; the old spelling stays accepted so nothing breaks.
1063
+ id: z
337
1064
  .string()
1065
+ .optional()
338
1066
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.source_id),
1067
+ source_id: z
1068
+ .string()
1069
+ .optional()
1070
+ .describe("Deprecated alias for `id`."),
339
1071
  mode: z
340
1072
  .enum(["content", "url", "both"])
341
1073
  .optional()
342
1074
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.mode),
1075
+ offset: z
1076
+ .number()
1077
+ .int()
1078
+ .min(0)
1079
+ .optional()
1080
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.offset),
1081
+ limit: z
1082
+ .number()
1083
+ .int()
1084
+ .min(1)
1085
+ .max(INSPECT_CHAR_BUDGET)
1086
+ .optional()
1087
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.limit),
1088
+ expiry_seconds: z
1089
+ .number()
1090
+ .int()
1091
+ .min(1)
1092
+ .optional()
1093
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.expiry_seconds),
343
1094
  };
344
1095
  const deleteSchema = {
345
- id: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.id),
1096
+ ids: z
1097
+ .array(z.string())
1098
+ .min(1)
1099
+ .optional()
1100
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.ids),
1101
+ id: z
1102
+ .string()
1103
+ .optional()
1104
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.id),
346
1105
  kind: z
347
1106
  .enum(["memory", "knowledge"])
348
1107
  .optional()
349
1108
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.kind),
350
1109
  };
1110
+ // Output schemas, declared only where the result is genuinely structured.
1111
+ // Query stays prose: its payload IS text, and forcing it into fields would
1112
+ // duplicate the rendered context rather than replace it.
1113
+ const listOutputSchema = {
1114
+ kind: z.enum(["memory", "knowledge"]),
1115
+ items: z.array(z.object({
1116
+ id: z.string(),
1117
+ title: z.string().optional(),
1118
+ type: z.string().optional(),
1119
+ content: z.string().optional(),
1120
+ })),
1121
+ shown: z.number(),
1122
+ total: z.number(),
1123
+ page: z.number(),
1124
+ has_more: z.boolean(),
1125
+ };
1126
+ const ingestOutputSchema = {
1127
+ id: z.string().optional(),
1128
+ success_count: z.number(),
1129
+ failed_count: z.number(),
1130
+ indexing_pending: z.boolean(),
1131
+ };
1132
+ const deleteOutputSchema = {
1133
+ ids: z.array(z.string()),
1134
+ kind: z.enum(["memory", "knowledge"]),
1135
+ deleted: z.boolean(),
1136
+ /** Absent when the server confirmed a removal without saying how many. */
1137
+ deleted_count: z.number().optional(),
1138
+ deleted_count_known: z.boolean().optional(),
1139
+ partial: z.boolean().optional(),
1140
+ reason: z.string().optional(),
1141
+ };
1142
+ const statusSchema = {
1143
+ ids: z
1144
+ .array(z.string())
1145
+ .min(1)
1146
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STATUS].params.ids),
1147
+ };
351
1148
  const deleteMemorySchema = {
352
1149
  memory_id: z
353
1150
  .string()
354
1151
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE_MEMORY].params.memory_id),
355
1152
  };
356
- const readOnly = { readOnlyHint: true, idempotentHint: true };
357
- const searchAnnotations = {
1153
+ // `destructiveHint` was missing from the annotations type, so no tool could
1154
+ // declare it — and the MCP spec defaults it to TRUE for any non-readonly
1155
+ // tool. A spec-following host therefore read hydradb_ingest as destructive
1156
+ // and could prompt the user before every proactive save, which is exactly the
1157
+ // behaviour the instructions now ask for. Meanwhile hydradb_delete, which IS
1158
+ // destructive, was landing there only by absence — one refactor adding an
1159
+ // explicit `readOnlyHint: false` would have flipped it.
1160
+ //
1161
+ // All four are stated on every tool so none of them depends on a default.
1162
+ const readOnly = {
358
1163
  readOnlyHint: true,
1164
+ destructiveHint: false,
1165
+ idempotentHint: true,
1166
+ openWorldHint: true,
1167
+ };
1168
+ const searchAnnotations = readOnly;
1169
+ /** Adds context; never removes any. Repeating it is not a no-op. */
1170
+ const additiveWrite = {
1171
+ readOnlyHint: false,
1172
+ destructiveHint: false,
1173
+ idempotentHint: false,
359
1174
  openWorldHint: true,
1175
+ };
1176
+ /** Removes context irreversibly. Repeating it is harmless once it is gone. */
1177
+ const destructive = {
1178
+ readOnlyHint: false,
1179
+ destructiveHint: true,
360
1180
  idempotentHint: true,
1181
+ openWorldHint: true,
361
1182
  };
362
1183
  // --- Canonical tools ---
363
- register(TOOL_NAMES.QUERY, querySchema, (args) => runQuery(args), searchAnnotations);
364
- register(TOOL_NAMES.INGEST, ingestSchema, async (args) => {
1184
+ register(TOOL_NAMES.QUERY, querySchema, (args, extra) => runQuery(args, extra?.signal), searchAnnotations);
1185
+ register(TOOL_NAMES.INGEST, ingestSchema, async (args, extra) => {
365
1186
  const a = args;
366
1187
  const hasTurns = a.turns != null && a.turns.length > 0;
1188
+ // A conversation is a memory by definition; there is no knowledge document
1189
+ // made of user/assistant pairs. Reject rather than quietly ingesting it as
1190
+ // the wrong family.
1191
+ if (hasTurns && a.kind === "knowledge") {
1192
+ throw new Error(`${TOOL_NAMES.INGEST} cannot ingest \`turns\` as knowledge — conversations are memories. ` +
1193
+ `Use \`text\` for a knowledge document, or drop \`kind\`.`);
1194
+ }
1195
+ // The handler strips memory-only fields before calling the wrapper on the
1196
+ // knowledge path, which means the wrapper's own guard never sees them —
1197
+ // so without this they would be dropped in silence, which is the exact
1198
+ // behaviour that guard exists to prevent.
1199
+ if (a.kind === "knowledge") {
1200
+ const memoryOnlyGiven = [
1201
+ ["source_id", a.source_id],
1202
+ ["infer", a.infer],
1203
+ ["is_markdown", a.is_markdown],
1204
+ ["user_name", a.user_name],
1205
+ ["metadata", a.metadata],
1206
+ ["observation_date", a.observation_date],
1207
+ ]
1208
+ .filter(([, value]) => value != null)
1209
+ .map(([name]) => name);
1210
+ if (memoryOnlyGiven.length > 0) {
1211
+ throw new Error(`${TOOL_NAMES.INGEST} does not support ${memoryOnlyGiven.join(", ")} for ` +
1212
+ `kind "knowledge" — those apply to memory ingestion only. Drop them, or ` +
1213
+ `ingest this as a memory.`);
1214
+ }
1215
+ }
367
1216
  // `text` and `turns` are mutually exclusive — reject rather than silently
368
1217
  // dropping one (the documented "exactly one" contract).
369
1218
  if (hasTurns && a.text != null) {
370
1219
  throw new Error(`${TOOL_NAMES.INGEST} accepts either \`text\` (a note) or \`turns\` (a conversation), not both.`);
371
1220
  }
372
1221
  if (a.turns != null && a.turns.length > 0) {
373
- const sourceId = a.source_id ?? `mcp-conversation-${Date.now()}`;
1222
+ const sourceId = a.source_id ?? generatedSourceId();
374
1223
  // Forward every option the canonical schema accepts so none is
375
1224
  // silently dropped on the conversation path.
376
1225
  return runIngestConversation(a.turns, sourceId, {
@@ -378,44 +1227,92 @@ export function createHydraDBServer(hydraOverride) {
378
1227
  infer: a.infer,
379
1228
  title: a.title,
380
1229
  isMarkdown: a.is_markdown,
381
- });
1230
+ overwrite: a.overwrite,
1231
+ }, extra?.signal);
382
1232
  }
383
1233
  if (a.text != null) {
384
1234
  return runStore({
385
1235
  text: a.text,
1236
+ kind: a.kind,
386
1237
  title: a.title,
387
1238
  source_id: a.source_id,
388
1239
  infer: a.infer,
389
1240
  is_markdown: a.is_markdown,
390
- });
1241
+ overwrite: a.overwrite,
1242
+ metadata: a.metadata,
1243
+ observation_date: a.observation_date,
1244
+ }, extra?.signal);
391
1245
  }
392
1246
  throw new Error(`${TOOL_NAMES.INGEST} requires either \`text\` (a note) or \`turns\` (a conversation).`);
393
- });
394
- register(TOOL_NAMES.LIST, listSchema, (args) => {
1247
+ }, additiveWrite, ingestOutputSchema);
1248
+ register(TOOL_NAMES.LIST, listSchema, (args, extra) => {
395
1249
  const a = args;
396
- if ((a.kind ?? "memory") === "knowledge") {
397
- return runListSources({ source_ids: a.source_ids });
1250
+ // Compare as SETS. These are filters, so order carries no meaning —
1251
+ // rejecting ["a","b"] against ["b","a"] refuses a request that asked
1252
+ // for exactly one thing, which is worse than the ambiguity the check
1253
+ // exists to catch.
1254
+ // Compare DISTINCT members. An earlier version compared lengths and
1255
+ // union size, which called ["a","b"] and ["a","a"] equivalent — same
1256
+ // length, same union size — and then silently listed records the
1257
+ // deprecated filter had excluded.
1258
+ const sameIds = (x, y) => {
1259
+ const left = new Set(x);
1260
+ const right = new Set(y);
1261
+ return left.size === right.size && [...left].every((v) => right.has(v));
1262
+ };
1263
+ if (a.ids != null &&
1264
+ a.source_ids != null &&
1265
+ !sameIds(a.ids, a.source_ids)) {
1266
+ throw new Error(`${TOOL_NAMES.LIST} received different values for \`ids\` and its deprecated ` +
1267
+ `alias \`source_ids\`. Pass only \`ids\`.`);
398
1268
  }
399
- return runListMemories();
400
- }, readOnly);
401
- register(TOOL_NAMES.INSPECT, inspectSchema, (args) => runInspect(args), readOnly);
402
- register(TOOL_NAMES.DELETE, deleteSchema, (args) => runDelete(args));
1269
+ const ids = a.ids ?? a.source_ids;
1270
+ if (a.kind === "knowledge") {
1271
+ return runListSources({ source_ids: ids, page: a.page, page_size: a.page_size }, extra?.signal);
1272
+ }
1273
+ return runListMemories({ source_ids: ids, page: a.page, page_size: a.page_size }, extra?.signal);
1274
+ }, readOnly, listOutputSchema);
1275
+ register(TOOL_NAMES.INSPECT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
1276
+ register(TOOL_NAMES.DELETE, deleteSchema, (args, extra) => runDelete(toDeleteArgs(args), extra?.signal), destructive, deleteOutputSchema);
1277
+ register(TOOL_NAMES.STATUS, statusSchema, (args, extra) => runStatus(args, extra?.signal), readOnly);
403
1278
  // --- Deprecated aliases ---
404
- register(TOOL_NAMES.SEARCH, querySchema, (args) => runQuery(args), searchAnnotations);
405
- register(TOOL_NAMES.STORE, storeSchema, (args) => runStore(args));
406
- register(TOOL_NAMES.INGEST_CONVERSATION, conversationSchema, (args) => {
407
- const a = args;
408
- // The deprecated alias keeps its historical shape (user_name only; infer
409
- // on, no title/markdown). The canonical hydradb_ingest forwards the rest.
410
- return runIngestConversation(a.turns, a.source_id, { userName: a.user_name });
411
- });
412
- register(TOOL_NAMES.LIST_MEMORIES, {}, () => runListMemories(), readOnly);
413
- register(TOOL_NAMES.LIST_SOURCES, listSourcesSchema, (args) => runListSources(args), readOnly);
414
- register(TOOL_NAMES.FETCH_CONTENT, inspectSchema, (args) => runInspect(args), readOnly);
415
- register(TOOL_NAMES.DELETE_MEMORY, deleteMemorySchema, (args) => {
416
- const { memory_id } = args;
417
- return runDelete({ id: memory_id, kind: "memory" });
418
- });
1279
+ //
1280
+ // Registered only when HYDRADB_MCP_LEGACY_TOOLS is set. Off by default.
1281
+ //
1282
+ // Twelve tools is not the problem; adversarial naming is. The alias names are
1283
+ // systematically better literal matches for how users phrase requests than
1284
+ // the canonical ones "search my memory" matches hydra_db_search exactly
1285
+ // while hydradb_query needs a synonym step, "list my memories" matches
1286
+ // hydra_db_list_memories verbatim while hydradb_list additionally needs
1287
+ // `kind` inferred. Every canonical tool has a competitor that wins on surface
1288
+ // form AND requires fewer inferential steps to parameterise, against nothing
1289
+ // but a "DEPRECATED" prefix — a negative instruction losing to a positive
1290
+ // lexical match.
1291
+ //
1292
+ // The cost of losing that contest is real capability, not just a warning:
1293
+ // hydra_db_ingest_conversation cannot set kind, overwrite, title, infer or
1294
+ // is_markdown, and hydra_db_store has no path to `turns`. A model that picks
1295
+ // the alias because the name matched silently gets the lesser tool.
1296
+ //
1297
+ // They also cost every conversation ~1,800 tokens of manifest — 55% of it —
1298
+ // before a single call is made.
1299
+ if (legacyToolsEnabled()) {
1300
+ register(TOOL_NAMES.SEARCH, querySchema, (args, extra) => runQuery(args, extra?.signal), searchAnnotations);
1301
+ register(TOOL_NAMES.STORE, storeSchema, (args, extra) => runStore(args, extra?.signal), additiveWrite);
1302
+ register(TOOL_NAMES.INGEST_CONVERSATION, conversationSchema, (args, extra) => {
1303
+ const a = args;
1304
+ // The deprecated alias keeps its historical shape (user_name only; infer
1305
+ // on, no title/markdown). The canonical hydradb_ingest forwards the rest.
1306
+ return runIngestConversation(a.turns, a.source_id, { userName: a.user_name }, extra?.signal);
1307
+ }, additiveWrite);
1308
+ register(TOOL_NAMES.LIST_MEMORIES, {}, (_args, extra) => runListMemories({}, extra?.signal), readOnly);
1309
+ register(TOOL_NAMES.LIST_SOURCES, listSourcesSchema, (args, extra) => runListSources(args, extra?.signal), readOnly);
1310
+ register(TOOL_NAMES.FETCH_CONTENT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
1311
+ register(TOOL_NAMES.DELETE_MEMORY, deleteMemorySchema, (args, extra) => {
1312
+ const { memory_id } = args;
1313
+ return runDelete({ ids: [memory_id], kind: "memory" }, extra?.signal);
1314
+ }, destructive);
1315
+ }
419
1316
  return server.server;
420
1317
  }
421
1318
  //# sourceMappingURL=server.js.map