@hydradb/mcp 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { GraphConfig } from "./config.js";
1
2
  import { HydraDB } from "./hydra/index.js";
2
3
  /** Stop accepting tool calls. Idempotent. */
3
4
  export declare function beginShutdown(): void;
@@ -18,7 +19,12 @@ export declare function inFlightCount(): number;
18
19
  export declare function legacyToolsEnabled(env?: NodeJS.ProcessEnv): boolean;
19
20
  /** Test-only: reset the once-per-process alias warning dedupe. */
20
21
  export declare function __resetAliasWarnings(): void;
21
- export declare function createHydraDBServer(hydraOverride?: HydraDB): import("@modelcontextprotocol/sdk/server").Server<{
22
+ export declare function createHydraDBServer(hydraOverride?: HydraDB,
23
+ /**
24
+ * Graph scope/gating override, for tests and embedders. Without it the graph
25
+ * config is read from the environment exactly as the rest of the config is.
26
+ */
27
+ graphOverride?: Partial<GraphConfig>): import("@modelcontextprotocol/sdk/server").Server<{
22
28
  method: string;
23
29
  params?: {
24
30
  [x: string]: unknown;
package/dist/server.js CHANGED
@@ -1,10 +1,12 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { createRequire } from "node:module";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { z } from "zod";
5
6
  import { toAddMemoryResponse, toMemoryList, toSourceList } from "./adapters.js";
6
- import { resolveConfig } from "./config.js";
7
+ import { resolveConfig, resolveGraphConfig } from "./config.js";
7
8
  import { renderRecalledContext } from "./context.js";
9
+ import { COLLECTION_PATTERN, MAX_BODY_BYTES, renderRows } from "./cypher.js";
8
10
  import { SERVER_INSTRUCTIONS, TOOL_DESCRIPTIONS } from "./descriptions.js";
9
11
  import { HydraDB } from "./hydra/index.js";
10
12
  import { logger } from "./logger.js";
@@ -238,7 +240,12 @@ export function legacyToolsEnabled(env = process.env) {
238
240
  export function __resetAliasWarnings() {
239
241
  warnedAliases.clear();
240
242
  }
241
- export function createHydraDBServer(hydraOverride) {
243
+ export function createHydraDBServer(hydraOverride,
244
+ /**
245
+ * Graph scope/gating override, for tests and embedders. Without it the graph
246
+ * config is read from the environment exactly as the rest of the config is.
247
+ */
248
+ graphOverride) {
242
249
  const server = new McpServer({
243
250
  name: "hydradb-mcp",
244
251
  version: SERVER_VERSION,
@@ -246,8 +253,10 @@ export function createHydraDBServer(hydraOverride) {
246
253
  instructions: SERVER_INSTRUCTIONS,
247
254
  });
248
255
  let hydra;
256
+ let graphConfig;
249
257
  if (hydraOverride) {
250
258
  hydra = hydraOverride;
259
+ graphConfig = { ...resolveGraphConfig(), ...graphOverride };
251
260
  }
252
261
  else {
253
262
  const config = resolveConfig();
@@ -261,6 +270,7 @@ export function createHydraDBServer(hydraOverride) {
261
270
  : {}),
262
271
  ...(config.maxRetries != null ? { maxRetries: config.maxRetries } : {}),
263
272
  });
273
+ graphConfig = { ...config.graph, ...graphOverride };
264
274
  logger.info(`Hydra DB connected (database=${config.database}, collection=${config.collection})`);
265
275
  }
266
276
  // --- Handlers (shared by canonical tools and their deprecated aliases) ---
@@ -282,6 +292,8 @@ export function createHydraDBServer(hydraOverride) {
282
292
  metadataFilters: args.metadata_filters,
283
293
  numRelatedChunks: args.num_related_chunks,
284
294
  graphContext: args.graph_context ?? true,
295
+ database: args.database,
296
+ collection: args.collection,
285
297
  // Host-owned default (CONTRACT §2 rule 5), but only where it means
286
298
  // something: alpha balances dense against sparse retrieval in HYBRID
287
299
  // mode, and an `operator` switches the query to text retrieval (see
@@ -422,6 +434,8 @@ export function createHydraDBServer(hydraOverride) {
422
434
  text: args.text,
423
435
  title: args.title ?? defaultTitle(args.text),
424
436
  ...memoryOnly,
437
+ database: args.database,
438
+ collection: args.collection,
425
439
  // Default stays true. The SDK retries POSTs, so upsert is what keeps a
426
440
  // retried ingest from duplicating — flipping this default would trade a
427
441
  // silent overwrite for a silent duplicate.
@@ -455,6 +469,8 @@ export function createHydraDBServer(hydraOverride) {
455
469
  isMarkdown: opts?.isMarkdown,
456
470
  customInstructions: INGEST_INSTRUCTIONS,
457
471
  upsert: opts?.overwrite ?? true,
472
+ database: opts?.database,
473
+ collection: opts?.collection,
458
474
  }, { signal });
459
475
  const res = toAddMemoryResponse(raw);
460
476
  const conversationId = createdId(res) ?? sourceId;
@@ -506,6 +522,8 @@ export function createHydraDBServer(hydraOverride) {
506
522
  ids: args.source_ids,
507
523
  page: args.page,
508
524
  pageSize: args.page_size,
525
+ database: args.database,
526
+ collection: args.collection,
509
527
  }, { signal });
510
528
  const { memories, page } = toMemoryList(raw);
511
529
  if (memories.length === 0) {
@@ -554,6 +572,8 @@ export function createHydraDBServer(hydraOverride) {
554
572
  ids: args.source_ids,
555
573
  page: args.page,
556
574
  pageSize: args.page_size,
575
+ database: args.database,
576
+ collection: args.collection,
557
577
  }, { signal });
558
578
  const { sources, page } = toSourceList(raw);
559
579
  if (sources.length === 0) {
@@ -693,6 +713,8 @@ export function createHydraDBServer(hydraOverride) {
693
713
  offset: a.offset,
694
714
  limit: a.limit,
695
715
  expiry_seconds: a.expiry_seconds,
716
+ database: a.database,
717
+ collection: a.collection,
696
718
  };
697
719
  }
698
720
  async function runInspect(args, signal) {
@@ -701,6 +723,8 @@ export function createHydraDBServer(hydraOverride) {
701
723
  id: args.source_id,
702
724
  mode: args.mode ?? "content",
703
725
  expirySeconds: args.expiry_seconds,
726
+ database: args.database,
727
+ collection: args.collection,
704
728
  }, { signal });
705
729
  // Soft failure: return a normal (non-error) text result, matching v1.
706
730
  if (!res.success || res.error) {
@@ -723,7 +747,11 @@ export function createHydraDBServer(hydraOverride) {
723
747
  }
724
748
  async function runStatus(args, signal) {
725
749
  logger.debug(`${TOOL_NAMES.STATUS}: ${args.ids.join(", ")}`);
726
- const res = await hydra.context.ingestionStatus({ ids: args.ids }, { signal });
750
+ const res = await hydra.context.ingestionStatus({
751
+ ids: args.ids,
752
+ database: args.database,
753
+ collection: args.collection,
754
+ }, { signal });
727
755
  const statuses = res.statuses ?? [];
728
756
  if (statuses.length === 0) {
729
757
  return textResult(`No indexing status found for: ${args.ids.join(", ")}. ` +
@@ -834,12 +862,17 @@ export function createHydraDBServer(hydraOverride) {
834
862
  throw new Error(`${TOOL_NAMES.DELETE} requires \`ids\` (or \`id\`). Ids come from ` +
835
863
  `${TOOL_NAMES.QUERY} or ${TOOL_NAMES.LIST} — do not guess one.`);
836
864
  }
837
- return { ids, kind: a.kind };
865
+ return { ids, kind: a.kind, database: a.database, collection: a.collection };
838
866
  }
839
867
  async function runDelete(args, signal) {
840
868
  const kind = args.kind ?? "memory";
841
869
  logger.debug(`${TOOL_NAMES.DELETE}: ${kind} ${args.ids.join(", ")}`);
842
- const res = await hydra.context.delete({ ids: args.ids, kind }, { signal });
870
+ const res = await hydra.context.delete({
871
+ ids: args.ids,
872
+ kind,
873
+ database: args.database,
874
+ collection: args.collection,
875
+ }, { signal });
843
876
  // `userMemoryDeleted` is a COUNT on the v2 wire — a live delete returned
844
877
  // `{"deletedCount":1,"userMemoryDeleted":1}` — and the SDK types it as a
845
878
  // number. The v1 memory-delete handler returns a boolean for the same
@@ -865,6 +898,183 @@ export function createHydraDBServer(hydraOverride) {
865
898
  }
866
899
  return deleteReport(kind, args.ids, res, removed, removedCount);
867
900
  }
901
+ // --- BYOG graph handlers ---
902
+ /**
903
+ * The scope a graph call runs against.
904
+ *
905
+ * Resolved per call rather than captured once, so a caller can address a
906
+ * second graph without reconfiguring the server. The collection name is
907
+ * validated here because the server's rule is a documented charset and
908
+ * rejecting locally names it, where the remote failure is a bare 400.
909
+ */
910
+ function graphScope(args) {
911
+ const database = args.database?.trim() || graphConfig.database;
912
+ const collection = args.collection?.trim() || graphConfig.collection;
913
+ if (!database) {
914
+ throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
915
+ "or pass `database` on this call.");
916
+ }
917
+ if (!COLLECTION_PATTERN.test(collection)) {
918
+ throw new Error(`Invalid graph collection name "${collection}". Collection names must match ` +
919
+ "[A-Za-z0-9][A-Za-z0-9_-]{0,63} — start with a letter or digit, then letters, " +
920
+ "digits, underscores or hyphens, up to 64 characters.");
921
+ }
922
+ return { database, collection };
923
+ }
924
+ /**
925
+ * Refuse an oversized request before it goes out.
926
+ *
927
+ * The server answers a body over 256 KiB with 413, but only after receiving
928
+ * all of it — so on the bulk loads where this actually happens, the remote
929
+ * check is the slowest possible way to learn the batch was too big. Measured
930
+ * in BYTES, not characters: the cap is on the encoded body, and non-ASCII
931
+ * property values are where a "small enough" batch stops being one.
932
+ */
933
+ function assertBodyFits(body) {
934
+ let bytes;
935
+ try {
936
+ // The WHOLE body, not just the caller's two fields. `database` and
937
+ // `collection` are serialised alongside the query, so measuring
938
+ // without them let a payload sitting just under the cap pass here and
939
+ // be rejected remotely with a 413 — after the entire thing had been
940
+ // uploaded, which is the outcome this check exists to avoid.
941
+ bytes = Buffer.byteLength(JSON.stringify(body) ?? "", "utf8");
942
+ }
943
+ catch {
944
+ throw new Error("`params` could not be serialised to JSON — it must contain only plain " +
945
+ "values (strings, numbers, booleans, null, arrays, objects).");
946
+ }
947
+ if (bytes > MAX_BODY_BYTES) {
948
+ throw new Error(`This request is ${Math.round(bytes / 1024)} KiB, over Hydra DB's ` +
949
+ `${MAX_BODY_BYTES / 1024} KiB limit. Split it into batches — send rows in ` +
950
+ "chunks with `UNWIND $rows AS row ...` (about 500 rows per call is a good start).");
951
+ }
952
+ }
953
+ /**
954
+ * The single Cypher entry point: reads and writes go through one tool.
955
+ *
956
+ * This deliberately does NOT inspect the query. An earlier version lexed it
957
+ * to classify reads vs writes and to pre-reject constructs the server
958
+ * refuses; both were a client reimplementing the server's rules, able only
959
+ * to agree with it or to be wrong. The server rejects unsupported
960
+ * constructs before executing anything and says so more precisely than we
961
+ * did, so the query goes out as written.
962
+ */
963
+ async function runGraphCypher(args, signal) {
964
+ const scope = graphScope(args);
965
+ assertBodyFits({ ...scope, query: args.query, params: args.params });
966
+ logger.debug(`${TOOL_NAMES.GRAPH_QUERY}: ${scope.database}/${scope.collection}`);
967
+ const rows = await hydra.graph.query({ ...scope, query: args.query, params: args.params }, { signal });
968
+ // A write with no RETURN legitimately yields zero rows. Reporting that as
969
+ // "no results" invites the caller to retry a write that already committed.
970
+ if (rows.length === 0) {
971
+ return structuredResult(
972
+ // Zero rows means one of two things and this does not guess which:
973
+ // a read that matched nothing, or a write with no RETURN clause.
974
+ // Naming both keeps a caller from re-running a write that already
975
+ // committed because the result "looked empty".
976
+ `The query ran against ${scope.database}/${scope.collection} and returned ` +
977
+ "0 rows. For a read that means nothing matched; for a write with no RETURN " +
978
+ "clause it is the expected result and the write has been applied — do not " +
979
+ "re-run it to check.", { database: scope.database, collection: scope.collection, rows: [], row_count: 0 });
980
+ }
981
+ const maxRows = args.max_rows ?? 100;
982
+ const rendered = renderRows(rows, { maxRows });
983
+ return structuredResult(`${rows.length} row(s) from ${scope.database}/${scope.collection}:\n\n${rendered}`, {
984
+ database: scope.database,
985
+ collection: scope.collection,
986
+ // Bounded the same way the prose is: structured output is a different
987
+ // encoding of the same answer, not a bypass of its limits.
988
+ rows: rows.slice(0, maxRows),
989
+ row_count: rows.length,
990
+ });
991
+ }
992
+ async function runGraphCollections(args, signal) {
993
+ const database = args.database?.trim() || graphConfig.database;
994
+ if (!database) {
995
+ throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
996
+ "or pass `database` on this call.");
997
+ }
998
+ logger.debug(`${TOOL_NAMES.GRAPH_COLLECTIONS}: ${database}`);
999
+ const collections = await hydra.graph.listCollections({ database }, { signal });
1000
+ if (collections.length === 0) {
1001
+ return structuredResult(`No graph collections in ${database} yet. Collections are created by their ` +
1002
+ `first write — run one with ${TOOL_NAMES.GRAPH_QUERY}.`, { database, collections: [], count: 0 });
1003
+ }
1004
+ return structuredResult(`${collections.length} graph collection(s) in ${database}:\n` +
1005
+ collections.map((name) => ` - ${name}`).join("\n"), { database, collections, count: collections.length });
1006
+ }
1007
+ async function runGraphAdmin(args, signal) {
1008
+ const database = args.database?.trim() || graphConfig.database;
1009
+ if (!database) {
1010
+ throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
1011
+ "or pass `database` on this call.");
1012
+ }
1013
+ logger.debug(`${TOOL_NAMES.GRAPH_ADMIN}: ${args.action} ${database}`);
1014
+ if (args.action === "create_database") {
1015
+ const res = await hydra.graph.createDatabase(database, { signal });
1016
+ return structuredResult(`Created graph database "${database}" (status: ${res.status ?? "ready"}). ` +
1017
+ "Collections are created by their first write; there is no create-collection step.", { action: args.action, database, status: res.status ?? "ready", created: true });
1018
+ }
1019
+ if (args.action === "drop_collection") {
1020
+ const collection = args.collection?.trim();
1021
+ if (!collection) {
1022
+ throw new Error(`${TOOL_NAMES.GRAPH_ADMIN} action "drop_collection" requires \`collection\` — ` +
1023
+ "the name of the graph to drop. Nothing was deleted.");
1024
+ }
1025
+ await hydra.graph.dropCollection({ database, collection }, { signal });
1026
+ // The endpoint is idempotent and does not report whether anything was
1027
+ // there, so this states what was requested rather than claiming a
1028
+ // removal that may not have had anything to remove.
1029
+ return structuredResult(`Dropped graph collection "${collection}" from ${database}, along with all its ` +
1030
+ "data. This call is idempotent, so it also succeeds when the collection did " +
1031
+ "not exist.", { action: args.action, database, collection, dropped: true });
1032
+ }
1033
+ if (args.action === "drop_database") {
1034
+ const res = await hydra.graph.dropDatabase(database, { signal });
1035
+ const dropped = res.deleted_collections ?? [];
1036
+ const listed = dropped.length > 0 ? ` Collections removed: ${dropped.join(", ")}.` : "";
1037
+ // Three outcomes, not two, and the third is "we were not told".
1038
+ //
1039
+ // `deleted: false` is a real, different result — the database predates
1040
+ // BYOG, so only its graph collections went and the database itself
1041
+ // remains. Reporting that as a full drop tells the user something is
1042
+ // gone that is still there.
1043
+ //
1044
+ // A MISSING `deleted` used to fall into the same branch as `true` and
1045
+ // claim a full drop. On a destructive, irreversible call that is the
1046
+ // wrong direction to guess in: the server did not establish that
1047
+ // outcome, so it is not asserted. Say what is known and how to check.
1048
+ let text;
1049
+ if (res.deleted === true) {
1050
+ text = `Dropped graph database "${database}" and everything in it.${listed}`;
1051
+ }
1052
+ else if (res.deleted === false) {
1053
+ text =
1054
+ `Dropped the graph collections in "${database}", but NOT the database itself — ` +
1055
+ "it was created through the standard database API, so remove it there." +
1056
+ listed;
1057
+ }
1058
+ else {
1059
+ text =
1060
+ `Dropped the graph collections in "${database}".${listed} The server did not ` +
1061
+ "report whether the database itself was removed, so that is unconfirmed — " +
1062
+ "check with your database listing rather than assuming it is gone.";
1063
+ }
1064
+ return structuredResult(text, {
1065
+ action: args.action,
1066
+ database,
1067
+ // Omitted rather than guessed when the server did not say, matching
1068
+ // how the memory delete path reports an unknown count.
1069
+ ...(typeof res.deleted === "boolean"
1070
+ ? { database_deleted: res.deleted }
1071
+ : { database_deleted_known: false }),
1072
+ deleted_collections: dropped,
1073
+ });
1074
+ }
1075
+ throw new Error(`${TOOL_NAMES.GRAPH_ADMIN} received an unknown action "${args.action}". ` +
1076
+ 'Valid actions are "create_database", "drop_collection" and "drop_database".');
1077
+ }
868
1078
  // --- Registration helper ---
869
1079
  function register(name, inputSchema, handler, annotations, outputSchema) {
870
1080
  const desc = TOOL_DESCRIPTIONS[name];
@@ -885,6 +1095,18 @@ export function createHydraDBServer(hydraOverride) {
885
1095
  }, wrapped);
886
1096
  }
887
1097
  // --- Input schemas ---
1098
+ const scopeSchema = {
1099
+ database: z
1100
+ .string()
1101
+ .min(1)
1102
+ .optional()
1103
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.database),
1104
+ collection: z
1105
+ .string()
1106
+ .min(1)
1107
+ .optional()
1108
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.collection),
1109
+ };
888
1110
  const querySchema = {
889
1111
  query: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.query),
890
1112
  kind: z
@@ -929,6 +1151,7 @@ export function createHydraDBServer(hydraOverride) {
929
1151
  .max(5)
930
1152
  .optional()
931
1153
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.num_related_chunks),
1154
+ ...scopeSchema,
932
1155
  };
933
1156
  const storeSchema = {
934
1157
  text: z
@@ -957,6 +1180,7 @@ export function createHydraDBServer(hydraOverride) {
957
1180
  .boolean()
958
1181
  .optional()
959
1182
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.overwrite),
1183
+ ...scopeSchema,
960
1184
  };
961
1185
  const ingestMetadataSchema = {
962
1186
  metadata: z
@@ -1018,6 +1242,7 @@ export function createHydraDBServer(hydraOverride) {
1018
1242
  .string()
1019
1243
  .optional()
1020
1244
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.user_name),
1245
+ ...scopeSchema,
1021
1246
  };
1022
1247
  const listSchema = {
1023
1248
  // Required, not defaulted. `hydradb_list({})` used to return memories only
@@ -1048,12 +1273,17 @@ export function createHydraDBServer(hydraOverride) {
1048
1273
  .max(100)
1049
1274
  .optional()
1050
1275
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.page_size),
1276
+ ...scopeSchema,
1051
1277
  };
1052
1278
  const listSourcesSchema = {
1053
1279
  source_ids: z
1054
1280
  .array(z.string())
1055
1281
  .optional()
1056
1282
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES].params.source_ids),
1283
+ ...scopeSchema,
1284
+ };
1285
+ const listMemoriesSchema = {
1286
+ ...scopeSchema,
1057
1287
  };
1058
1288
  const inspectSchema = {
1059
1289
  // CONTRACT §1 says a source's identifier field is `id`, but this surface
@@ -1091,6 +1321,7 @@ export function createHydraDBServer(hydraOverride) {
1091
1321
  .min(1)
1092
1322
  .optional()
1093
1323
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.expiry_seconds),
1324
+ ...scopeSchema,
1094
1325
  };
1095
1326
  const deleteSchema = {
1096
1327
  ids: z
@@ -1106,6 +1337,7 @@ export function createHydraDBServer(hydraOverride) {
1106
1337
  .enum(["memory", "knowledge"])
1107
1338
  .optional()
1108
1339
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.kind),
1340
+ ...scopeSchema,
1109
1341
  };
1110
1342
  // Output schemas, declared only where the result is genuinely structured.
1111
1343
  // Query stays prose: its payload IS text, and forcing it into fields would
@@ -1144,11 +1376,78 @@ export function createHydraDBServer(hydraOverride) {
1144
1376
  .array(z.string())
1145
1377
  .min(1)
1146
1378
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STATUS].params.ids),
1379
+ ...scopeSchema,
1147
1380
  };
1148
1381
  const deleteMemorySchema = {
1149
1382
  memory_id: z
1150
1383
  .string()
1151
1384
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE_MEMORY].params.memory_id),
1385
+ ...scopeSchema,
1386
+ };
1387
+ // --- BYOG graph schemas ---
1388
+ const graphScopeSchema = {
1389
+ database: z
1390
+ .string()
1391
+ .min(1)
1392
+ .optional()
1393
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.database),
1394
+ collection: z
1395
+ .string()
1396
+ .min(1)
1397
+ .optional()
1398
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.collection),
1399
+ };
1400
+ /**
1401
+ * Cypher text is bounded like every other free-text input on this server.
1402
+ * The 256 KiB body cap covers query plus params together and is checked in
1403
+ * the handler; this only stops an absurd query string before it gets there.
1404
+ */
1405
+ const MAX_CYPHER_CHARS = 100000;
1406
+ const graphCypherSchema = {
1407
+ query: z
1408
+ .string()
1409
+ .min(1, { message: "query must not be empty" })
1410
+ .max(MAX_CYPHER_CHARS, {
1411
+ message: `query must be at most ${MAX_CYPHER_CHARS} characters`,
1412
+ })
1413
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.query),
1414
+ params: z
1415
+ .record(z.unknown())
1416
+ .optional()
1417
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.params),
1418
+ ...graphScopeSchema,
1419
+ max_rows: z
1420
+ .number()
1421
+ .int()
1422
+ .min(1)
1423
+ .max(1000)
1424
+ .optional()
1425
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.max_rows),
1426
+ };
1427
+ const graphCollectionsSchema = {
1428
+ database: z
1429
+ .string()
1430
+ .optional()
1431
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_COLLECTIONS].params.database),
1432
+ };
1433
+ const graphAdminSchema = {
1434
+ action: z
1435
+ .enum(["create_database", "drop_collection", "drop_database"])
1436
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_ADMIN].params.action),
1437
+ database: z
1438
+ .string()
1439
+ .optional()
1440
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_ADMIN].params.database),
1441
+ collection: z
1442
+ .string()
1443
+ .optional()
1444
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_ADMIN].params.collection),
1445
+ };
1446
+ const graphRowsOutputSchema = {
1447
+ database: z.string(),
1448
+ collection: z.string(),
1449
+ rows: z.array(z.record(z.unknown())),
1450
+ row_count: z.number(),
1152
1451
  };
1153
1452
  // `destructiveHint` was missing from the annotations type, so no tool could
1154
1453
  // declare it — and the MCP spec defaults it to TRUE for any non-readonly
@@ -1228,6 +1527,8 @@ export function createHydraDBServer(hydraOverride) {
1228
1527
  title: a.title,
1229
1528
  isMarkdown: a.is_markdown,
1230
1529
  overwrite: a.overwrite,
1530
+ database: a.database,
1531
+ collection: a.collection,
1231
1532
  }, extra?.signal);
1232
1533
  }
1233
1534
  if (a.text != null) {
@@ -1241,6 +1542,8 @@ export function createHydraDBServer(hydraOverride) {
1241
1542
  overwrite: a.overwrite,
1242
1543
  metadata: a.metadata,
1243
1544
  observation_date: a.observation_date,
1545
+ database: a.database,
1546
+ collection: a.collection,
1244
1547
  }, extra?.signal);
1245
1548
  }
1246
1549
  throw new Error(`${TOOL_NAMES.INGEST} requires either \`text\` (a note) or \`turns\` (a conversation).`);
@@ -1268,13 +1571,49 @@ export function createHydraDBServer(hydraOverride) {
1268
1571
  }
1269
1572
  const ids = a.ids ?? a.source_ids;
1270
1573
  if (a.kind === "knowledge") {
1271
- return runListSources({ source_ids: ids, page: a.page, page_size: a.page_size }, extra?.signal);
1574
+ return runListSources({
1575
+ source_ids: ids,
1576
+ page: a.page,
1577
+ page_size: a.page_size,
1578
+ database: a.database,
1579
+ collection: a.collection,
1580
+ }, extra?.signal);
1272
1581
  }
1273
- return runListMemories({ source_ids: ids, page: a.page, page_size: a.page_size }, extra?.signal);
1582
+ return runListMemories({
1583
+ source_ids: ids,
1584
+ page: a.page,
1585
+ page_size: a.page_size,
1586
+ database: a.database,
1587
+ collection: a.collection,
1588
+ }, extra?.signal);
1274
1589
  }, readOnly, listOutputSchema);
1275
1590
  register(TOOL_NAMES.INSPECT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
1276
1591
  register(TOOL_NAMES.DELETE, deleteSchema, (args, extra) => runDelete(toDeleteArgs(args), extra?.signal), destructive, deleteOutputSchema);
1277
1592
  register(TOOL_NAMES.STATUS, statusSchema, (args, extra) => runStatus(args, extra?.signal), readOnly);
1593
+ // --- BYOG graph tools (PRO-1681) ---
1594
+ //
1595
+ // A separate product surface from the memory/knowledge tools above: property
1596
+ // graphs the user models and owns, addressed in Cypher. Registered by
1597
+ // default so the capability is discoverable — the feature exists today and
1598
+ // no client surfaces it — and gated by one switch:
1599
+ //
1600
+ // HYDRADB_MCP_GRAPH_TOOLS=0 withholds all three, for memory-only users
1601
+ // who do not want the manifest cost.
1602
+ //
1603
+ // There is no read-only mode. It would have to classify Cypher client-side
1604
+ // to decide what to refuse, which is a heuristic — offering it would invite
1605
+ // operators to trust a guarantee it could not make. Withholding the tools
1606
+ // outright is a real guarantee; that is the switch above.
1607
+ if (graphConfig.enabled) {
1608
+ register(TOOL_NAMES.GRAPH_QUERY, graphCypherSchema, (args, extra) => runGraphCypher(args, extra?.signal),
1609
+ // Destructive, not read-only: this one tool runs arbitrary Cypher, so
1610
+ // DELETE is as reachable through it as MATCH. Annotating it any other
1611
+ // way would tell a host it is safe to auto-approve, which is exactly
1612
+ // the claim the removed read/write split could not actually back.
1613
+ destructive, graphRowsOutputSchema);
1614
+ register(TOOL_NAMES.GRAPH_COLLECTIONS, graphCollectionsSchema, (args, extra) => runGraphCollections(args, extra?.signal), readOnly);
1615
+ register(TOOL_NAMES.GRAPH_ADMIN, graphAdminSchema, (args, extra) => runGraphAdmin(args, extra?.signal), destructive);
1616
+ }
1278
1617
  // --- Deprecated aliases ---
1279
1618
  //
1280
1619
  // Registered only when HYDRADB_MCP_LEGACY_TOOLS is set. Off by default.
@@ -1303,14 +1642,23 @@ export function createHydraDBServer(hydraOverride) {
1303
1642
  const a = args;
1304
1643
  // The deprecated alias keeps its historical shape (user_name only; infer
1305
1644
  // 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);
1645
+ return runIngestConversation(a.turns, a.source_id, {
1646
+ userName: a.user_name,
1647
+ database: a.database,
1648
+ collection: a.collection,
1649
+ }, extra?.signal);
1307
1650
  }, additiveWrite);
1308
- register(TOOL_NAMES.LIST_MEMORIES, {}, (_args, extra) => runListMemories({}, extra?.signal), readOnly);
1651
+ register(TOOL_NAMES.LIST_MEMORIES, listMemoriesSchema, (args, extra) => runListMemories(args, extra?.signal), readOnly);
1309
1652
  register(TOOL_NAMES.LIST_SOURCES, listSourcesSchema, (args, extra) => runListSources(args, extra?.signal), readOnly);
1310
1653
  register(TOOL_NAMES.FETCH_CONTENT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
1311
1654
  register(TOOL_NAMES.DELETE_MEMORY, deleteMemorySchema, (args, extra) => {
1312
- const { memory_id } = args;
1313
- return runDelete({ ids: [memory_id], kind: "memory" }, extra?.signal);
1655
+ const a = args;
1656
+ return runDelete({
1657
+ ids: [a.memory_id],
1658
+ kind: "memory",
1659
+ database: a.database,
1660
+ collection: a.collection,
1661
+ }, extra?.signal);
1314
1662
  }, destructive);
1315
1663
  }
1316
1664
  return server.server;