@hydradb/mcp 1.1.0 → 1.2.0

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