@hydradb/mcp 1.0.0 → 1.1.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,76 +1,103 @@
1
+ import { createRequire } from "node:module";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { z } from "zod";
3
- import { HydraDBClient } from "./client.js";
4
+ import { toAddMemoryResponse, toMemoryList, toRecallResponse, toSourceList } from "./adapters.js";
5
+ import { resolveConfig } from "./config.js";
4
6
  import { buildRecalledContext } from "./context.js";
5
- import { TOOL_DESCRIPTIONS, SERVER_INSTRUCTIONS } from "./descriptions.js";
7
+ import { SERVER_INSTRUCTIONS, TOOL_DESCRIPTIONS } from "./descriptions.js";
8
+ import { HydraDB } from "./hydra/index.js";
6
9
  import { logger } from "./logger.js";
7
- import { TOOL_NAMES } from "./tool-names.js";
8
- const DEFAULT_SUB_TENANT = "hydra-db-mcp";
9
- function getConfig() {
10
- const apiKey = process.env.HYDRA_DB_API_KEY;
11
- if (!apiKey) {
12
- throw new Error("HYDRA_DB_API_KEY environment variable is required");
13
- }
14
- const tenantId = process.env.HYDRA_DB_TENANT_ID;
15
- if (!tenantId) {
16
- throw new Error("HYDRA_DB_TENANT_ID environment variable is required");
17
- }
18
- const subTenantId = process.env.HYDRA_DB_SUB_TENANT_ID || DEFAULT_SUB_TENANT;
19
- return { apiKey, tenantId, subTenantId };
10
+ import { ALIAS_REPLACEMENTS, DEPRECATED_TOOL_NAMES, TOOL_NAMES } from "./tool-names.js";
11
+ // Host-owned default: silently attached to ingest so Hydra DB extracts the kind
12
+ // of personal context this server cares about. Injected here (not in the
13
+ // portable wrapper) because it is MCP-specific host behaviour.
14
+ const INGEST_INSTRUCTIONS = "Focus on extracting user preferences, habits, opinions, likes, dislikes, " +
15
+ "goals, and recurring themes. Capture any stated or implied personal context " +
16
+ "that would help personalise future interactions.";
17
+ // Read the version from package.json rather than repeating it here: the literal
18
+ // this replaces sat at 1.0.0 through the whole 1.x line, so every client saw
19
+ // stale version metadata. `../package.json` resolves to the package root from
20
+ // both `src/` (tsx) and `dist/` (published build).
21
+ const require = createRequire(import.meta.url);
22
+ const { version: SERVER_VERSION } = require("../package.json");
23
+ function textResult(text) {
24
+ return { content: [{ type: "text", text }] };
25
+ }
26
+ /**
27
+ * What a query actually searched, for the result strings. A query over `all`
28
+ * must not report "memories" — that phrasing is what taught callers the MCP
29
+ * was memory-only in the first place.
30
+ */
31
+ function resultNoun(kind, count) {
32
+ const one = count === 1;
33
+ if (kind === "memory")
34
+ return one ? "memory" : "memories";
35
+ if (kind === "knowledge")
36
+ return one ? "knowledge source" : "knowledge sources";
37
+ return one ? "context item" : "context items";
20
38
  }
21
- export function createHydraDBServer() {
39
+ const turnSchema = z.object({
40
+ user: z.string().describe("The user's message"),
41
+ assistant: z.string().describe("The assistant's response"),
42
+ });
43
+ // Deprecated aliases emit exactly one stderr warning PER PROCESS naming the
44
+ // canonical replacement (CONTRACT §3). The dedupe state is module-scoped so the
45
+ // guarantee holds across multiple server instances in the same process, and is
46
+ // intentionally NOT routed through `logger` — the warning must surface
47
+ // regardless of HYDRA_DB_LOG_LEVEL.
48
+ const warnedAliases = new Set();
49
+ function warnDeprecatedAlias(name) {
50
+ if (warnedAliases.has(name))
51
+ return;
52
+ warnedAliases.add(name);
53
+ const replacement = ALIAS_REPLACEMENTS[name] ?? "a canonical tool";
54
+ console.error(`[hydradb-mcp] Tool "${name}" is deprecated and will be removed in a future major version; use "${replacement}" instead.`);
55
+ }
56
+ /** Test-only: reset the once-per-process alias warning dedupe. */
57
+ export function __resetAliasWarnings() {
58
+ warnedAliases.clear();
59
+ }
60
+ export function createHydraDBServer(hydraOverride) {
22
61
  const server = new McpServer({
23
62
  name: "hydradb-mcp",
24
- version: "1.0.0",
63
+ version: SERVER_VERSION,
25
64
  }, {
26
65
  instructions: SERVER_INSTRUCTIONS,
27
66
  });
28
- const config = getConfig();
29
- const client = new HydraDBClient(config.apiKey, config.tenantId, config.subTenantId);
30
- // --- Search ---
31
- const desc = TOOL_DESCRIPTIONS[TOOL_NAMES.SEARCH];
32
- server.registerTool(TOOL_NAMES.SEARCH, {
33
- title: desc.title,
34
- description: desc.description,
35
- inputSchema: {
36
- query: z.string().describe(desc.params.query),
37
- max_results: z
38
- .number()
39
- .min(1)
40
- .max(50)
41
- .optional()
42
- .describe(desc.params.max_results),
43
- mode: z
44
- .enum(["fast", "thinking"])
45
- .optional()
46
- .describe(desc.params.mode),
47
- graph_context: z
48
- .boolean()
49
- .optional()
50
- .describe(desc.params.graph_context),
51
- },
52
- annotations: {
53
- readOnlyHint: true,
54
- openWorldHint: true,
55
- idempotentHint: true,
56
- },
57
- }, async (args) => {
58
- const { query, max_results, mode, graph_context, } = args;
59
- logger.debug(`${TOOL_NAMES.SEARCH}: "${query}"`);
60
- const res = await client.recall(query, {
61
- maxResults: max_results ?? 10,
62
- mode: mode ?? "thinking",
63
- graphContext: graph_context ?? true,
67
+ let hydra;
68
+ if (hydraOverride) {
69
+ hydra = hydraOverride;
70
+ }
71
+ else {
72
+ const config = resolveConfig();
73
+ hydra = new HydraDB({
74
+ token: config.apiKey,
75
+ database: config.database,
76
+ collection: config.collection,
77
+ ...(config.baseUrl != null ? { baseUrl: config.baseUrl } : {}),
64
78
  });
79
+ logger.info(`Hydra DB connected (database=${config.database}, collection=${config.collection})`);
80
+ }
81
+ // --- Handlers (shared by canonical tools and their deprecated aliases) ---
82
+ async function runQuery(args) {
83
+ // Host-owned default (CONTRACT §2 rule 5): search BOTH families. This tool
84
+ // used to pin `kind: "memory"`, which made every ingested knowledge source
85
+ // unreachable from the MCP — `hydradb_list`/`hydradb_inspect` could browse
86
+ // knowledge but nothing could search it.
87
+ const kind = args.kind ?? "all";
88
+ logger.debug(`${TOOL_NAMES.QUERY}: "${args.query}" (kind=${kind})`);
89
+ const raw = await hydra.context.query({
90
+ query: args.query,
91
+ kind,
92
+ maxResults: args.max_results ?? 10,
93
+ mode: args.mode ?? "thinking",
94
+ graphContext: args.graph_context ?? true,
95
+ alpha: 0.8,
96
+ recencyBias: 0,
97
+ });
98
+ const res = toRecallResponse(raw);
65
99
  if (!res.chunks || res.chunks.length === 0) {
66
- return {
67
- content: [
68
- {
69
- type: "text",
70
- text: "No relevant memories found in Hydra DB.",
71
- },
72
- ],
73
- };
100
+ return textResult(`No relevant ${resultNoun(kind)} found in Hydra DB.`);
74
101
  }
75
102
  const contextStr = buildRecalledContext(res);
76
103
  const summary = res.chunks.slice(0, 10).map((c, i) => {
@@ -82,240 +109,312 @@ export function createHydraDBServer() {
82
109
  : c.chunk_content;
83
110
  return `${i + 1}. ${snippet}${score}`;
84
111
  });
85
- return {
86
- content: [
87
- {
88
- type: "text",
89
- text: `Found ${res.chunks.length} memories:\n\n${summary.join("\n")}\n\n---\nFull context:\n${contextStr}`,
90
- },
91
- ],
92
- };
93
- });
94
- // --- Store ---
95
- const storeDesc = TOOL_DESCRIPTIONS[TOOL_NAMES.STORE];
96
- server.registerTool(TOOL_NAMES.STORE, {
97
- title: storeDesc.title,
98
- description: storeDesc.description,
99
- inputSchema: {
100
- text: z.string().describe(storeDesc.params.text),
101
- title: z
102
- .string()
103
- .optional()
104
- .describe(storeDesc.params.title),
105
- source_id: z
106
- .string()
107
- .optional()
108
- .describe(storeDesc.params.source_id),
109
- infer: z
110
- .boolean()
111
- .optional()
112
- .describe(storeDesc.params.infer),
113
- is_markdown: z
114
- .boolean()
115
- .optional()
116
- .describe(storeDesc.params.is_markdown),
117
- },
118
- }, async (args) => {
119
- const { text, title, source_id, infer, is_markdown } = args;
120
- logger.debug(`${TOOL_NAMES.STORE}: "${text.slice(0, 50)}..."`);
121
- const res = await client.ingestText(text, {
122
- sourceId: source_id,
123
- title: title ?? "MCP Memory",
124
- infer: infer ?? true,
125
- isMarkdown: is_markdown ?? false,
112
+ return textResult(`Found ${res.chunks.length} ${resultNoun(kind, res.chunks.length)}:\n\n${summary.join("\n")}\n\n---\nFull context:\n${contextStr}`);
113
+ }
114
+ async function runStore(args) {
115
+ logger.debug(`${TOOL_NAMES.INGEST}: "${args.text.slice(0, 50)}..."`);
116
+ const raw = await hydra.context.ingest({
117
+ kind: "memory",
118
+ 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,
126
125
  });
127
- const preview = text.length > 80 ? `${text.slice(0, 80)}...` : text;
128
- return {
129
- content: [
130
- {
131
- type: "text",
132
- text: `Saved to Hydra DB (${res.success_count} success, ${res.failed_count} failed): "${preview}"`,
133
- },
134
- ],
135
- };
136
- });
137
- // --- Ingest Conversation ---
138
- const ingestDesc = TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION];
139
- const turnSchema = z.object({
140
- user: z.string().describe("The user's message"),
141
- assistant: z.string().describe("The assistant's response"),
142
- });
143
- server.registerTool(TOOL_NAMES.INGEST_CONVERSATION, {
144
- title: ingestDesc.title,
145
- description: ingestDesc.description,
146
- inputSchema: {
147
- turns: z
148
- .array(turnSchema)
149
- .min(1)
150
- .describe(ingestDesc.params.turns),
151
- source_id: z
152
- .string()
153
- .describe(ingestDesc.params.source_id),
154
- user_name: z
155
- .string()
156
- .optional()
157
- .describe(ingestDesc.params.user_name),
158
- },
159
- }, async (args) => {
160
- const { turns, source_id, user_name } = args;
161
- logger.debug(`${TOOL_NAMES.INGEST_CONVERSATION}: ${turns.length} turns -> ${source_id}`);
162
- const res = await client.ingestConversation(turns, source_id, user_name);
163
- return {
164
- content: [
165
- {
166
- type: "text",
167
- text: `Ingested ${turns.length} conversation turn(s) into Hydra DB (source: ${source_id}, success: ${res.success_count}, failed: ${res.failed_count})`,
168
- },
169
- ],
170
- };
171
- });
172
- // --- List Memories ---
173
- const listDesc = TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_MEMORIES];
174
- server.registerTool(TOOL_NAMES.LIST_MEMORIES, {
175
- title: listDesc.title,
176
- description: listDesc.description,
177
- inputSchema: {},
178
- annotations: {
179
- readOnlyHint: true,
180
- idempotentHint: true,
181
- },
182
- }, async () => {
183
- logger.debug(TOOL_NAMES.LIST_MEMORIES);
184
- const res = await client.listMemories();
185
- const memories = res.user_memories ?? [];
126
+ 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}"`);
129
+ }
130
+ async function runIngestConversation(turns, sourceId, opts) {
131
+ logger.debug(`${TOOL_NAMES.INGEST}: ${turns.length} turns -> ${sourceId}`);
132
+ const raw = await hydra.context.ingest({
133
+ kind: "memory",
134
+ pairs: turns,
135
+ sourceId,
136
+ userName: opts?.userName ?? "User",
137
+ infer: opts?.infer ?? true,
138
+ title: opts?.title,
139
+ isMarkdown: opts?.isMarkdown,
140
+ customInstructions: INGEST_INSTRUCTIONS,
141
+ upsert: true,
142
+ });
143
+ 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})`);
145
+ }
146
+ async function runListMemories() {
147
+ logger.debug(TOOL_NAMES.LIST);
148
+ const raw = await hydra.context.list({ kind: "memory" });
149
+ const memories = toMemoryList(raw);
186
150
  if (memories.length === 0) {
187
- return {
188
- content: [
189
- {
190
- type: "text",
191
- text: "No memories stored yet.",
192
- },
193
- ],
194
- };
151
+ return textResult("No memories stored yet.");
195
152
  }
196
153
  const lines = memories.map((m, i) => `${i + 1}. [${m.memory_id}] ${m.memory_content.slice(0, 150)}`);
197
- return {
198
- content: [
199
- {
200
- type: "text",
201
- text: `${memories.length} memories:\n\n${lines.join("\n")}`,
202
- },
203
- ],
204
- };
205
- });
206
- // --- Delete Memory ---
207
- const deleteDesc = TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE_MEMORY];
208
- server.registerTool(TOOL_NAMES.DELETE_MEMORY, {
209
- title: deleteDesc.title,
210
- description: deleteDesc.description,
211
- inputSchema: {
212
- memory_id: z.string().describe(deleteDesc.params.memory_id),
213
- },
214
- }, async (args) => {
215
- const { memory_id } = args;
216
- logger.debug(`${TOOL_NAMES.DELETE_MEMORY}: ${memory_id}`);
217
- const res = await client.deleteMemory(memory_id);
218
- if (res.user_memory_deleted) {
219
- return {
220
- content: [
221
- {
222
- type: "text",
223
- text: `Deleted memory: ${memory_id}`,
224
- },
225
- ],
226
- };
227
- }
228
- return {
229
- content: [
230
- {
231
- type: "text",
232
- text: `Memory ${memory_id} was not found or already deleted.`,
233
- },
234
- ],
235
- };
236
- });
237
- // --- Fetch Content ---
238
- const fetchDesc = TOOL_DESCRIPTIONS[TOOL_NAMES.FETCH_CONTENT];
239
- server.registerTool(TOOL_NAMES.FETCH_CONTENT, {
240
- title: fetchDesc.title,
241
- description: fetchDesc.description,
242
- inputSchema: {
243
- source_id: z.string().describe(fetchDesc.params.source_id),
244
- mode: z
245
- .enum(["content", "url", "both"])
246
- .optional()
247
- .describe(fetchDesc.params.mode),
248
- },
249
- annotations: {
250
- readOnlyHint: true,
251
- idempotentHint: true,
252
- },
253
- }, async (args) => {
254
- const { source_id, mode } = args;
255
- logger.debug(`${TOOL_NAMES.FETCH_CONTENT}: ${source_id}`);
256
- const res = await client.fetchContent(source_id, mode ?? "content");
257
- if (!res.success || res.error) {
258
- return {
259
- content: [
260
- {
261
- type: "text",
262
- text: `Could not fetch source ${source_id}: ${res.error ?? "unknown error"}`,
263
- },
264
- ],
265
- };
266
- }
267
- const content = res.content ?? res.content_base64 ?? "(no text content)";
268
- return {
269
- content: [
270
- {
271
- type: "text",
272
- text: `Source: ${source_id}\n\n${content}`,
273
- },
274
- ],
275
- };
276
- });
277
- // --- List Sources ---
278
- const sourcesDesc = TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES];
279
- server.registerTool(TOOL_NAMES.LIST_SOURCES, {
280
- title: sourcesDesc.title,
281
- description: sourcesDesc.description,
282
- inputSchema: {
283
- source_ids: z
284
- .array(z.string())
285
- .optional()
286
- .describe(sourcesDesc.params.source_ids),
287
- },
288
- annotations: {
289
- readOnlyHint: true,
290
- idempotentHint: true,
291
- },
292
- }, async (args) => {
293
- const { source_ids } = args;
294
- logger.debug(TOOL_NAMES.LIST_SOURCES);
295
- const res = await client.listSources(source_ids);
296
- if (!res.sources || res.sources.length === 0) {
297
- return {
298
- content: [
299
- {
300
- type: "text",
301
- text: "No sources found.",
302
- },
303
- ],
304
- };
154
+ return textResult(`${memories.length} memories:\n\n${lines.join("\n")}`);
155
+ }
156
+ async function runListSources(args) {
157
+ logger.debug(TOOL_NAMES.LIST);
158
+ const raw = await hydra.context.list({
159
+ kind: "knowledge",
160
+ ids: args.source_ids,
161
+ });
162
+ const { sources, total } = toSourceList(raw);
163
+ if (sources.length === 0) {
164
+ return textResult("No sources found.");
305
165
  }
306
- const lines = res.sources.map((s, i) => {
166
+ const lines = sources.map((s, i) => {
307
167
  const title = s.title ? ` — ${s.title}` : "";
308
168
  const type = s.type ? ` (${s.type})` : "";
309
169
  return `${i + 1}. [${s.id}]${title}${type}`;
310
170
  });
311
- return {
312
- content: [
313
- {
314
- type: "text",
315
- text: `${res.total} sources:\n\n${lines.join("\n")}`,
316
- },
317
- ],
318
- };
171
+ return textResult(`${total} sources:\n\n${lines.join("\n")}`);
172
+ }
173
+ async function runInspect(args) {
174
+ logger.debug(`${TOOL_NAMES.INSPECT}: ${args.source_id}`);
175
+ const res = await hydra.context.inspect({
176
+ id: args.source_id,
177
+ mode: args.mode ?? "content",
178
+ });
179
+ // Soft failure: return a normal (non-error) text result, matching v1.
180
+ if (!res.success || res.error) {
181
+ return textResult(`Could not fetch source ${args.source_id}: ${res.error ?? "unknown error"}`);
182
+ }
183
+ const content = res.content ?? res.contentBase64 ?? "(no text content)";
184
+ return textResult(`Source: ${args.source_id}\n\n${content}`);
185
+ }
186
+ /**
187
+ * Three outcomes, not two. A delete that removed nothing is either the
188
+ * benign idempotent case (the server succeeded, there was nothing there) or
189
+ * a refusal (the server returned success:false and told us why). Collapsing
190
+ * both into "not found or already deleted" states a cause we did not
191
+ * observe, and it is the reassuring one: the caller is told their data is
192
+ * gone when the server just declined to remove it.
193
+ */
194
+ function deleteReport(kind, id, res, removed) {
195
+ const noun = kind === "knowledge" ? "source" : "memory";
196
+ if (removed) {
197
+ return textResult(`Deleted ${noun}: ${id}`);
198
+ }
199
+ if (res.success === false) {
200
+ 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.`);
204
+ }
205
+ const Noun = noun.charAt(0).toUpperCase() + noun.slice(1);
206
+ return textResult(`${Noun} ${id} was not found or already deleted.`);
207
+ }
208
+ /** The server's own explanation, preferring the per-item error over the summary. */
209
+ function deleteFailureReason(res) {
210
+ const items = Array.isArray(res.results) ? res.results : [];
211
+ for (const item of items) {
212
+ if (item != null && typeof item === "object") {
213
+ const error = item.error;
214
+ if (typeof error === "string" && error !== "")
215
+ return error;
216
+ }
217
+ }
218
+ return res.message !== "" ? res.message : undefined;
219
+ }
220
+ async function runDelete(args) {
221
+ 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;
225
+ if (!removed) {
226
+ logger.warn(`${TOOL_NAMES.DELETE}: removed nothing for ${kind} ${args.id}`, { success: res.success, message: res.message, results: res.results });
227
+ }
228
+ return deleteReport(kind, args.id, res, removed);
229
+ }
230
+ // --- Registration helper ---
231
+ function register(name, inputSchema, handler, annotations) {
232
+ const desc = TOOL_DESCRIPTIONS[name];
233
+ const isDeprecated = DEPRECATED_TOOL_NAMES.includes(name);
234
+ const wrapped = isDeprecated
235
+ ? (args) => {
236
+ warnDeprecatedAlias(name);
237
+ return handler(args);
238
+ }
239
+ : handler;
240
+ server.registerTool(name, {
241
+ title: desc.title,
242
+ description: desc.description,
243
+ inputSchema: inputSchema,
244
+ ...(annotations ? { annotations } : {}),
245
+ }, wrapped);
246
+ }
247
+ // --- Input schemas ---
248
+ const querySchema = {
249
+ query: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.query),
250
+ kind: z
251
+ .enum(["memory", "knowledge", "all"])
252
+ .optional()
253
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.kind),
254
+ max_results: z
255
+ .number()
256
+ .min(1)
257
+ .max(50)
258
+ .optional()
259
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.max_results),
260
+ mode: z
261
+ .enum(["fast", "thinking"])
262
+ .optional()
263
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.mode),
264
+ graph_context: z
265
+ .boolean()
266
+ .optional()
267
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.graph_context),
268
+ };
269
+ const storeSchema = {
270
+ text: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.text),
271
+ title: z
272
+ .string()
273
+ .optional()
274
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.title),
275
+ source_id: z
276
+ .string()
277
+ .optional()
278
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.source_id),
279
+ infer: z
280
+ .boolean()
281
+ .optional()
282
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.infer),
283
+ is_markdown: z
284
+ .boolean()
285
+ .optional()
286
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.is_markdown),
287
+ };
288
+ const ingestSchema = {
289
+ ...storeSchema,
290
+ // Canonical ingest accepts EITHER `text` or `turns`, so `text` is optional
291
+ // here (the `hydra_db_store` alias keeps it required).
292
+ text: z
293
+ .string()
294
+ .optional()
295
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.text),
296
+ turns: z
297
+ .array(turnSchema)
298
+ .min(1)
299
+ .optional()
300
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.turns),
301
+ user_name: z
302
+ .string()
303
+ .optional()
304
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.user_name),
305
+ };
306
+ const conversationSchema = {
307
+ turns: z
308
+ .array(turnSchema)
309
+ .min(1)
310
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.turns),
311
+ source_id: z
312
+ .string()
313
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.source_id),
314
+ user_name: z
315
+ .string()
316
+ .optional()
317
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.user_name),
318
+ };
319
+ const listSchema = {
320
+ kind: z
321
+ .enum(["memory", "knowledge"])
322
+ .optional()
323
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.kind),
324
+ source_ids: z
325
+ .array(z.string())
326
+ .optional()
327
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.source_ids),
328
+ };
329
+ const listSourcesSchema = {
330
+ source_ids: z
331
+ .array(z.string())
332
+ .optional()
333
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES].params.source_ids),
334
+ };
335
+ const inspectSchema = {
336
+ source_id: z
337
+ .string()
338
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.source_id),
339
+ mode: z
340
+ .enum(["content", "url", "both"])
341
+ .optional()
342
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.mode),
343
+ };
344
+ const deleteSchema = {
345
+ id: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.id),
346
+ kind: z
347
+ .enum(["memory", "knowledge"])
348
+ .optional()
349
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.kind),
350
+ };
351
+ const deleteMemorySchema = {
352
+ memory_id: z
353
+ .string()
354
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE_MEMORY].params.memory_id),
355
+ };
356
+ const readOnly = { readOnlyHint: true, idempotentHint: true };
357
+ const searchAnnotations = {
358
+ readOnlyHint: true,
359
+ openWorldHint: true,
360
+ idempotentHint: true,
361
+ };
362
+ // --- Canonical tools ---
363
+ register(TOOL_NAMES.QUERY, querySchema, (args) => runQuery(args), searchAnnotations);
364
+ register(TOOL_NAMES.INGEST, ingestSchema, async (args) => {
365
+ const a = args;
366
+ const hasTurns = a.turns != null && a.turns.length > 0;
367
+ // `text` and `turns` are mutually exclusive — reject rather than silently
368
+ // dropping one (the documented "exactly one" contract).
369
+ if (hasTurns && a.text != null) {
370
+ throw new Error(`${TOOL_NAMES.INGEST} accepts either \`text\` (a note) or \`turns\` (a conversation), not both.`);
371
+ }
372
+ if (a.turns != null && a.turns.length > 0) {
373
+ const sourceId = a.source_id ?? `mcp-conversation-${Date.now()}`;
374
+ // Forward every option the canonical schema accepts so none is
375
+ // silently dropped on the conversation path.
376
+ return runIngestConversation(a.turns, sourceId, {
377
+ userName: a.user_name,
378
+ infer: a.infer,
379
+ title: a.title,
380
+ isMarkdown: a.is_markdown,
381
+ });
382
+ }
383
+ if (a.text != null) {
384
+ return runStore({
385
+ text: a.text,
386
+ title: a.title,
387
+ source_id: a.source_id,
388
+ infer: a.infer,
389
+ is_markdown: a.is_markdown,
390
+ });
391
+ }
392
+ throw new Error(`${TOOL_NAMES.INGEST} requires either \`text\` (a note) or \`turns\` (a conversation).`);
393
+ });
394
+ register(TOOL_NAMES.LIST, listSchema, (args) => {
395
+ const a = args;
396
+ if ((a.kind ?? "memory") === "knowledge") {
397
+ return runListSources({ source_ids: a.source_ids });
398
+ }
399
+ return runListMemories();
400
+ }, readOnly);
401
+ register(TOOL_NAMES.INSPECT, inspectSchema, (args) => runInspect(args), readOnly);
402
+ register(TOOL_NAMES.DELETE, deleteSchema, (args) => runDelete(args));
403
+ // --- 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" });
319
418
  });
320
419
  return server.server;
321
420
  }