@hydradb/mcp 1.2.2 → 1.4.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
@@ -8,7 +8,7 @@ import { resolveConfig, resolveGraphConfig } from "./config.js";
8
8
  import { renderRecalledContext } from "./context.js";
9
9
  import { COLLECTION_PATTERN, MAX_BODY_BYTES, renderRows } from "./cypher.js";
10
10
  import { SERVER_INSTRUCTIONS, TOOL_DESCRIPTIONS } from "./descriptions.js";
11
- import { HydraDB } from "./hydra/index.js";
11
+ import { assertCollectionAllowed, assertDatabaseAllowed, HydraDB } from "./hydra/index.js";
12
12
  import { logger } from "./logger.js";
13
13
  import { ALIAS_REPLACEMENTS, DEPRECATED_TOOL_NAMES, TOOL_NAMES } from "./tool-names.js";
14
14
  // Host-owned default: silently attached to ingest so Hydra DB extracts the kind
@@ -245,7 +245,7 @@ export function createHydraDBServer(hydraOverride,
245
245
  * Graph scope/gating override, for tests and embedders. Without it the graph
246
246
  * config is read from the environment exactly as the rest of the config is.
247
247
  */
248
- graphOverride) {
248
+ graphOverride, options = {}) {
249
249
  const server = new McpServer({
250
250
  name: "hydradb-mcp",
251
251
  version: SERVER_VERSION,
@@ -290,17 +290,25 @@ graphOverride) {
290
290
  operator: args.operator,
291
291
  ids: args.source_ids,
292
292
  metadataFilters: args.metadata_filters,
293
+ acl: args.acl,
293
294
  numRelatedChunks: args.num_related_chunks,
294
295
  graphContext: args.graph_context ?? true,
296
+ queryApps: args.query_apps,
295
297
  database: args.database,
296
298
  collection: args.collection,
299
+ collections: args.collections,
297
300
  // Host-owned default (CONTRACT §2 rule 5), but only where it means
298
301
  // something: alpha balances dense against sparse retrieval in HYBRID
299
302
  // mode, and an `operator` switches the query to text retrieval (see
300
303
  // the wrapper), where there are no two lanes to weigh. Injecting it
301
304
  // there would send a hybrid-only knob on a request that is not hybrid.
302
305
  alpha: args.operator != null ? undefined : 0.8,
303
- recencyBias: 0,
306
+ // Host-owned default (CONTRACT §2 rule 5), and 0 is also what the API
307
+ // applies when the field is omitted — so the default ranking is
308
+ // unchanged. It stops being a constant here because "what is the
309
+ // current state of X" is a different question from "what matches X",
310
+ // and only a caller that can raise this can ask the first one.
311
+ recencyBias: args.recency_bias ?? 0,
304
312
  }, { signal });
305
313
  // The renderer reads the SDK payload directly; there is no longer a
306
314
  // snake_case mirror to convert into.
@@ -522,6 +530,7 @@ graphOverride) {
522
530
  ids: args.source_ids,
523
531
  page: args.page,
524
532
  pageSize: args.page_size,
533
+ acl: args.acl,
525
534
  database: args.database,
526
535
  collection: args.collection,
527
536
  }, { signal });
@@ -572,6 +581,7 @@ graphOverride) {
572
581
  ids: args.source_ids,
573
582
  page: args.page,
574
583
  pageSize: args.page_size,
584
+ acl: args.acl,
575
585
  database: args.database,
576
586
  collection: args.collection,
577
587
  }, { signal });
@@ -713,16 +723,120 @@ graphOverride) {
713
723
  offset: a.offset,
714
724
  limit: a.limit,
715
725
  expiry_seconds: a.expiry_seconds,
726
+ acl: a.acl,
716
727
  database: a.database,
717
728
  collection: a.collection,
718
729
  };
719
730
  }
731
+ async function runSubgraph(args, signal) {
732
+ // Blank is rejected; anything else is forwarded byte for byte. The
733
+ // server treats an item id as opaque (ingest stores a caller's
734
+ // source_id verbatim), so trimming here could ask about a different
735
+ // item. Same rule as the CLI's `hydradb subgraph`.
736
+ const id = args.id ?? "";
737
+ if (id.trim() === "") {
738
+ throw new Error(`${TOOL_NAMES.SUBGRAPH} requires \`id\` — the value shown as [id: …] in ` +
739
+ `${TOOL_NAMES.QUERY} results or in [brackets] in ${TOOL_NAMES.LIST} output.`);
740
+ }
741
+ logger.debug(`${TOOL_NAMES.SUBGRAPH}: ${id}`);
742
+ const res = await hydra.context.subgraph({
743
+ id,
744
+ kind: args.kind,
745
+ depth: args.depth,
746
+ maxSources: args.max_sources,
747
+ acl: args.acl,
748
+ database: args.database,
749
+ collection: args.collection,
750
+ }, { signal });
751
+ // Soft failure, like inspect: the server's own message, flagged.
752
+ if (!res.success) {
753
+ return errorResult(`Could not read the subgraph of ${id}: ${res.message || "unknown error"}`);
754
+ }
755
+ const members = res.sources ?? [];
756
+ if (members.length === 0) {
757
+ return structuredResult(`No item with id ${id} was found in this collection, so there is no subgraph to show. ` +
758
+ `Ids come from ${TOOL_NAMES.QUERY} or ${TOOL_NAMES.LIST}; check the collection as well as the id.`,
759
+ // Same keys as a populated result: a client reading
760
+ // structuredContent should not have to branch on which shape it got.
761
+ {
762
+ seed_id: id,
763
+ member_count: 0,
764
+ max_depth_reached: 0,
765
+ truncated: false,
766
+ relations: [],
767
+ structural_link_count: 0,
768
+ structural_truncated: false,
769
+ members: [],
770
+ });
771
+ }
772
+ const hops = res.max_depth_reached ?? 0;
773
+ // Sorted once, then used for both the prose and structuredContent: a
774
+ // machine client that renders the members must not get a different
775
+ // order from the one a reader sees.
776
+ const ordered = [...members].sort((a, b) => a.depth - b.depth);
777
+ const lines = [
778
+ members.length === 1 && !res.is_truncated
779
+ ? `${id} stands alone: nothing in the graph links to it yet.`
780
+ : `${members.length} item${members.length === 1 ? "" : "s"} connected to ${id} through ${hops} hop${hops === 1 ? "" : "s"}` +
781
+ (res.is_truncated ? ` (clipped at max_sources; the subgraph continues)` : "") +
782
+ ":",
783
+ "",
784
+ ];
785
+ // discovered_relation is the MECHANISM (same_thread, parent, child, or a
786
+ // relates_to type); discovered_via is the member this one was reached
787
+ // FROM — another member's id, so the list is also a tree. The parent id
788
+ // is shortened in the prose because it appears in full on its own line
789
+ // and in structuredContent; the relation is what a reader scans for.
790
+ const shortId = (id) => (id.length > 14 ? `${id.slice(0, 12)}…` : id);
791
+ for (const m of ordered) {
792
+ const title = m.title?.trim() || m.app_external_id || "(untitled)";
793
+ const reached = m.depth === 0
794
+ ? "the item you started from"
795
+ : `${m.discovered_relation || "linked"}${m.discovered_via ? ` from ${shortId(m.discovered_via)}` : ""}`;
796
+ const kind = [m.app_provider, m.app_kind].filter(Boolean).join(" ");
797
+ lines.push(`- [id: ${m.source_id}] ${title}${kind ? ` (${kind})` : ""} — depth ${m.depth}, ${reached}`);
798
+ }
799
+ lines.push("", `${(res.relations ?? []).length} relation(s) among them; ` +
800
+ `${(res.auxiliary_relations ?? []).length} structural link(s) around them` +
801
+ (res.auxiliary_truncated ? " (structural links clipped)" : "") +
802
+ `. Pass any [id: …] to ${TOOL_NAMES.INSPECT} for its full content.`);
803
+ // The edges make it a graph rather than a list, so structuredContent
804
+ // carries them too, compacted to what a client can act on: both
805
+ // endpoints are Source nodes, so entity_id is the member's id. The
806
+ // structural links are about entities and comments, not members, so
807
+ // they stay a count plus their own clipped flag, exactly as the prose
808
+ // reports them.
809
+ const edges = (res.relations ?? []).map((r) => ({
810
+ from: r.source?.entity_id ?? null,
811
+ to: r.target?.entity_id ?? null,
812
+ type: r.relations?.[0]?.canonical_predicate ?? null,
813
+ }));
814
+ return structuredResult(lines.join("\n"), {
815
+ seed_id: res.seed_source_id || id,
816
+ member_count: members.length,
817
+ max_depth_reached: hops,
818
+ truncated: Boolean(res.is_truncated),
819
+ relations: edges,
820
+ structural_link_count: (res.auxiliary_relations ?? []).length,
821
+ structural_truncated: Boolean(res.auxiliary_truncated),
822
+ members: ordered.map((m) => ({
823
+ id: m.source_id,
824
+ title: m.title ?? null,
825
+ depth: m.depth,
826
+ discovered_via: m.discovered_via ?? null,
827
+ discovered_relation: m.discovered_relation ?? null,
828
+ app_provider: m.app_provider ?? null,
829
+ app_kind: m.app_kind ?? null,
830
+ })),
831
+ });
832
+ }
720
833
  async function runInspect(args, signal) {
721
834
  logger.debug(`${TOOL_NAMES.INSPECT}: ${args.source_id}`);
722
835
  const res = await hydra.context.inspect({
723
836
  id: args.source_id,
724
837
  mode: args.mode ?? "content",
725
838
  expirySeconds: args.expiry_seconds,
839
+ acl: args.acl,
726
840
  database: args.database,
727
841
  collection: args.collection,
728
842
  }, { signal });
@@ -898,6 +1012,34 @@ graphOverride) {
898
1012
  }
899
1013
  return deleteReport(kind, args.ids, res, removed, removedCount);
900
1014
  }
1015
+ /**
1016
+ * Which databases this connection can address, and which is the default.
1017
+ *
1018
+ * A confined connection answers from its allowed list without a network
1019
+ * call: the list IS the answer, and asking the API would only show
1020
+ * databases the connection is not permitted to use.
1021
+ */
1022
+ async function runDatabases() {
1023
+ const defaultDatabase = hydra.database;
1024
+ let databases;
1025
+ let confined = false;
1026
+ if (hydra.allowedDatabases) {
1027
+ databases = [...hydra.allowedDatabases];
1028
+ confined = true;
1029
+ }
1030
+ else {
1031
+ const listed = await hydra.databases.list();
1032
+ databases = (listed.databases ?? listed.tenantIds ?? []).filter(Boolean);
1033
+ }
1034
+ if (!databases.includes(defaultDatabase))
1035
+ databases.unshift(defaultDatabase);
1036
+ const lines = databases.map((d) => ` - ${d}${d === defaultDatabase ? " (default for this connection)" : ""}`);
1037
+ const note = confined
1038
+ ? "\nThis connection is confined to the database(s) above; any other name is refused. " +
1039
+ "The user chose this when approving the connection."
1040
+ : "\nPass `database` on any tool to work in another one.";
1041
+ return structuredResult(`${databases.length} database(s):\n${lines.join("\n")}${note}`, { databases, default: defaultDatabase, confined });
1042
+ }
901
1043
  // --- BYOG graph handlers ---
902
1044
  /**
903
1045
  * The scope a graph call runs against.
@@ -907,9 +1049,33 @@ graphOverride) {
907
1049
  * validated here because the server's rule is a documented charset and
908
1050
  * rejecting locally names it, where the remote failure is a bare 400.
909
1051
  */
1052
+ /**
1053
+ * The graph database a call runs against, checked against the connection's
1054
+ * allowed set. The graph client does not go through the context client's
1055
+ * scope(), so the same rule is applied here for every graph tool.
1056
+ */
1057
+ function graphDatabase(override) {
1058
+ const database = override?.trim() || graphConfig.database;
1059
+ if (database && database !== hydra.database) {
1060
+ assertDatabaseAllowed(database, hydra.allowedDatabases);
1061
+ }
1062
+ return database;
1063
+ }
1064
+ /**
1065
+ * The graph collection a call runs against, checked the same way the
1066
+ * database is. `drop_collection` makes an unchecked override destructive,
1067
+ * so confinement covers both axes or it covers nothing.
1068
+ */
1069
+ function graphCollection(override) {
1070
+ const collection = override?.trim() || graphConfig.collection;
1071
+ if (collection && collection !== hydra.collection) {
1072
+ assertCollectionAllowed(collection, hydra.allowedCollections);
1073
+ }
1074
+ return collection;
1075
+ }
910
1076
  function graphScope(args) {
911
- const database = args.database?.trim() || graphConfig.database;
912
- const collection = args.collection?.trim() || graphConfig.collection;
1077
+ const database = graphDatabase(args.database);
1078
+ const collection = graphCollection(args.collection);
913
1079
  if (!database) {
914
1080
  throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
915
1081
  "or pass `database` on this call.");
@@ -990,7 +1156,7 @@ graphOverride) {
990
1156
  });
991
1157
  }
992
1158
  async function runGraphCollections(args, signal) {
993
- const database = args.database?.trim() || graphConfig.database;
1159
+ const database = graphDatabase(args.database);
994
1160
  if (!database) {
995
1161
  throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
996
1162
  "or pass `database` on this call.");
@@ -1005,7 +1171,7 @@ graphOverride) {
1005
1171
  collections.map((name) => ` - ${name}`).join("\n"), { database, collections, count: collections.length });
1006
1172
  }
1007
1173
  async function runGraphAdmin(args, signal) {
1008
- const database = args.database?.trim() || graphConfig.database;
1174
+ const database = graphDatabase(args.database);
1009
1175
  if (!database) {
1010
1176
  throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
1011
1177
  "or pass `database` on this call.");
@@ -1022,6 +1188,13 @@ graphOverride) {
1022
1188
  throw new Error(`${TOOL_NAMES.GRAPH_ADMIN} action "drop_collection" requires \`collection\` — ` +
1023
1189
  "the name of the graph to drop. Nothing was deleted.");
1024
1190
  }
1191
+ // This action takes its collection directly rather than through
1192
+ // graphCollection(), because there is no default to fall back to, so
1193
+ // the confinement check has to be stated here as well. It is the one
1194
+ // place an unchecked collection would be irreversible.
1195
+ if (collection !== hydra.collection) {
1196
+ assertCollectionAllowed(collection, hydra.allowedCollections);
1197
+ }
1025
1198
  await hydra.graph.dropCollection({ database, collection }, { signal });
1026
1199
  // The endpoint is idempotent and does not report whether anything was
1027
1200
  // there, so this states what was requested rather than claiming a
@@ -1031,6 +1204,21 @@ graphOverride) {
1031
1204
  "not exist.", { action: args.action, database, collection, dropped: true });
1032
1205
  }
1033
1206
  if (args.action === "drop_database") {
1207
+ // This deletes EVERY graph collection in the database, so on a
1208
+ // collection-confined connection it cannot be performed within the
1209
+ // confinement: even an allowed database holds collections the user
1210
+ // never approved. The per-collection checks elsewhere cannot catch
1211
+ // this one, because the call names no collection at all. Refuse the
1212
+ // action outright rather than let the broadest destructive operation
1213
+ // be the way around the narrowest grant.
1214
+ if (hydra.allowedCollections) {
1215
+ throw new Error(`This connection is confined to collection ${hydra.allowedCollections
1216
+ .map((c) => `"${c}"`)
1217
+ .join(", ")}, and "drop_database" removes every collection in ` +
1218
+ `"${database}", including ones it was not granted. Nothing was deleted. ` +
1219
+ 'Use "drop_collection" for a collection this connection may use, or ' +
1220
+ "reconnect with wider access.");
1221
+ }
1034
1222
  const res = await hydra.graph.dropDatabase(database, { signal });
1035
1223
  const dropped = res.deleted_collections ?? [];
1036
1224
  const listed = dropped.length > 0 ? ` Collections removed: ${dropped.join(", ")}.` : "";
@@ -1151,6 +1339,25 @@ graphOverride) {
1151
1339
  .max(5)
1152
1340
  .optional()
1153
1341
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.num_related_chunks),
1342
+ recency_bias: z
1343
+ .number()
1344
+ .min(0)
1345
+ .max(1)
1346
+ .optional()
1347
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.recency_bias),
1348
+ query_apps: z
1349
+ .boolean()
1350
+ .optional()
1351
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.query_apps),
1352
+ collections: z
1353
+ .array(z.string().min(1))
1354
+ .min(1)
1355
+ .optional()
1356
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.collections),
1357
+ acl: z
1358
+ .array(z.string())
1359
+ .optional()
1360
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.acl),
1154
1361
  ...scopeSchema,
1155
1362
  };
1156
1363
  const storeSchema = {
@@ -1273,6 +1480,10 @@ graphOverride) {
1273
1480
  .max(100)
1274
1481
  .optional()
1275
1482
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.page_size),
1483
+ acl: z
1484
+ .array(z.string())
1485
+ .optional()
1486
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.acl),
1276
1487
  ...scopeSchema,
1277
1488
  };
1278
1489
  const listSourcesSchema = {
@@ -1280,9 +1491,43 @@ graphOverride) {
1280
1491
  .array(z.string())
1281
1492
  .optional()
1282
1493
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES].params.source_ids),
1494
+ acl: z
1495
+ .array(z.string())
1496
+ .optional()
1497
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.acl),
1283
1498
  ...scopeSchema,
1284
1499
  };
1285
1500
  const listMemoriesSchema = {
1501
+ acl: z
1502
+ .array(z.string())
1503
+ .optional()
1504
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.acl),
1505
+ ...scopeSchema,
1506
+ };
1507
+ const subgraphSchema = {
1508
+ id: z.string().min(1).describe(TOOL_DESCRIPTIONS[TOOL_NAMES.SUBGRAPH].params.id),
1509
+ kind: z
1510
+ .enum(["memory", "knowledge"])
1511
+ .optional()
1512
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.SUBGRAPH].params.kind),
1513
+ depth: z
1514
+ .number()
1515
+ .int()
1516
+ .min(1)
1517
+ .max(10)
1518
+ .optional()
1519
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.SUBGRAPH].params.depth),
1520
+ max_sources: z
1521
+ .number()
1522
+ .int()
1523
+ .min(1)
1524
+ .max(1000)
1525
+ .optional()
1526
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.SUBGRAPH].params.max_sources),
1527
+ acl: z
1528
+ .array(z.string())
1529
+ .optional()
1530
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.SUBGRAPH].params.acl),
1286
1531
  ...scopeSchema,
1287
1532
  };
1288
1533
  const inspectSchema = {
@@ -1321,6 +1566,10 @@ graphOverride) {
1321
1566
  .min(1)
1322
1567
  .optional()
1323
1568
  .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.expiry_seconds),
1569
+ acl: z
1570
+ .array(z.string())
1571
+ .optional()
1572
+ .describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.acl),
1324
1573
  ...scopeSchema,
1325
1574
  };
1326
1575
  const deleteSchema = {
@@ -1575,6 +1824,7 @@ graphOverride) {
1575
1824
  source_ids: ids,
1576
1825
  page: a.page,
1577
1826
  page_size: a.page_size,
1827
+ acl: a.acl,
1578
1828
  database: a.database,
1579
1829
  collection: a.collection,
1580
1830
  }, extra?.signal);
@@ -1583,13 +1833,21 @@ graphOverride) {
1583
1833
  source_ids: ids,
1584
1834
  page: a.page,
1585
1835
  page_size: a.page_size,
1836
+ acl: a.acl,
1586
1837
  database: a.database,
1587
1838
  collection: a.collection,
1588
1839
  }, extra?.signal);
1589
1840
  }, readOnly, listOutputSchema);
1590
1841
  register(TOOL_NAMES.INSPECT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
1842
+ register(TOOL_NAMES.SUBGRAPH, subgraphSchema, (args, extra) => runSubgraph(args, extra?.signal), readOnly);
1591
1843
  register(TOOL_NAMES.DELETE, deleteSchema, (args, extra) => runDelete(toDeleteArgs(args), extra?.signal), destructive, deleteOutputSchema);
1592
1844
  register(TOOL_NAMES.STATUS, statusSchema, (args, extra) => runStatus(args, extra?.signal), readOnly);
1845
+ // Only an OAuth connection carries a user's database decision, and only
1846
+ // there does an agent need a way to see it. Registering this for API-key
1847
+ // connections too would change their tool list, which must stay identical.
1848
+ if (options.oauthTools) {
1849
+ register(TOOL_NAMES.DATABASES, {}, () => runDatabases(), readOnly);
1850
+ }
1593
1851
  // --- BYOG graph tools (PRO-1681) ---
1594
1852
  //
1595
1853
  // A separate product surface from the memory/knowledge tools above: property