@hydradb/mcp 1.2.0 → 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/CHANGELOG.md +158 -0
- package/README.md +172 -2
- package/dist/config.d.ts +40 -0
- package/dist/config.js +40 -3
- package/dist/config.js.map +1 -1
- package/dist/cypher.d.ts +51 -0
- package/dist/cypher.js +145 -0
- package/dist/cypher.js.map +1 -0
- package/dist/descriptions.d.ts +55 -0
- package/dist/descriptions.js +170 -13
- package/dist/descriptions.js.map +1 -1
- package/dist/http-config.d.ts +160 -0
- package/dist/http-config.js +253 -0
- package/dist/http-config.js.map +1 -0
- package/dist/http.d.ts +31 -0
- package/dist/http.js +350 -0
- package/dist/http.js.map +1 -0
- package/dist/hydra/client.d.ts +36 -2
- package/dist/hydra/client.js +46 -14
- package/dist/hydra/client.js.map +1 -1
- package/dist/hydra/errors.d.ts +14 -0
- package/dist/hydra/errors.js +16 -0
- package/dist/hydra/errors.js.map +1 -1
- package/dist/hydra/graph.d.ts +82 -0
- package/dist/hydra/graph.js +217 -0
- package/dist/hydra/graph.js.map +1 -0
- package/dist/hydra/index.d.ts +3 -1
- package/dist/hydra/index.js +2 -1
- package/dist/hydra/index.js.map +1 -1
- package/dist/server.d.ts +7 -1
- package/dist/server.js +387 -12
- package/dist/server.js.map +1 -1
- package/dist/tool-names.d.ts +5 -0
- package/dist/tool-names.js +15 -0
- package/dist/tool-names.js.map +1 -1
- package/package.json +10 -2
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";
|
|
@@ -136,6 +138,23 @@ const turnSchema = z.object({
|
|
|
136
138
|
})
|
|
137
139
|
.describe("The assistant's response"),
|
|
138
140
|
});
|
|
141
|
+
/**
|
|
142
|
+
* `observation_date` is a CALENDAR date. The server answers anything finer with
|
|
143
|
+
* `400 INVALID_INPUT: observation_date "2026-08-17T00:00:00Z" is not a valid
|
|
144
|
+
* ISO-8601 date (want YYYY-MM-DD)`, and a model writing a date in JSON reaches
|
|
145
|
+
* for the date-time form first — so the date-time is accepted here and trimmed
|
|
146
|
+
* to the date the caller wrote, rather than left to fail as a 400 from a remote
|
|
147
|
+
* service after the request has gone out.
|
|
148
|
+
*
|
|
149
|
+
* Trimming is textual on purpose: it keeps the date as written, where converting
|
|
150
|
+
* to UTC first would move "2026-08-17T23:00:00-08:00" to the 18th and silently
|
|
151
|
+
* record a different day than the caller meant. The time of day is the only
|
|
152
|
+
* thing dropped, and it is the part the server has nowhere to store.
|
|
153
|
+
*
|
|
154
|
+
* Anything that is not a date at all still fails, before the network.
|
|
155
|
+
*/
|
|
156
|
+
const OBSERVATION_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$/;
|
|
157
|
+
const CALENDAR_DATE_LENGTH = "YYYY-MM-DD".length;
|
|
139
158
|
// Deprecated aliases emit exactly one stderr warning PER PROCESS naming the
|
|
140
159
|
// canonical replacement (CONTRACT §3). The dedupe state is module-scoped so the
|
|
141
160
|
// guarantee holds across multiple server instances in the same process, and is
|
|
@@ -221,7 +240,12 @@ export function legacyToolsEnabled(env = process.env) {
|
|
|
221
240
|
export function __resetAliasWarnings() {
|
|
222
241
|
warnedAliases.clear();
|
|
223
242
|
}
|
|
224
|
-
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) {
|
|
225
249
|
const server = new McpServer({
|
|
226
250
|
name: "hydradb-mcp",
|
|
227
251
|
version: SERVER_VERSION,
|
|
@@ -229,8 +253,10 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
229
253
|
instructions: SERVER_INSTRUCTIONS,
|
|
230
254
|
});
|
|
231
255
|
let hydra;
|
|
256
|
+
let graphConfig;
|
|
232
257
|
if (hydraOverride) {
|
|
233
258
|
hydra = hydraOverride;
|
|
259
|
+
graphConfig = { ...resolveGraphConfig(), ...graphOverride };
|
|
234
260
|
}
|
|
235
261
|
else {
|
|
236
262
|
const config = resolveConfig();
|
|
@@ -244,6 +270,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
244
270
|
: {}),
|
|
245
271
|
...(config.maxRetries != null ? { maxRetries: config.maxRetries } : {}),
|
|
246
272
|
});
|
|
273
|
+
graphConfig = { ...config.graph, ...graphOverride };
|
|
247
274
|
logger.info(`Hydra DB connected (database=${config.database}, collection=${config.collection})`);
|
|
248
275
|
}
|
|
249
276
|
// --- Handlers (shared by canonical tools and their deprecated aliases) ---
|
|
@@ -265,7 +292,14 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
265
292
|
metadataFilters: args.metadata_filters,
|
|
266
293
|
numRelatedChunks: args.num_related_chunks,
|
|
267
294
|
graphContext: args.graph_context ?? true,
|
|
268
|
-
|
|
295
|
+
database: args.database,
|
|
296
|
+
collection: args.collection,
|
|
297
|
+
// Host-owned default (CONTRACT §2 rule 5), but only where it means
|
|
298
|
+
// something: alpha balances dense against sparse retrieval in HYBRID
|
|
299
|
+
// mode, and an `operator` switches the query to text retrieval (see
|
|
300
|
+
// the wrapper), where there are no two lanes to weigh. Injecting it
|
|
301
|
+
// there would send a hybrid-only knob on a request that is not hybrid.
|
|
302
|
+
alpha: args.operator != null ? undefined : 0.8,
|
|
269
303
|
recencyBias: 0,
|
|
270
304
|
}, { signal });
|
|
271
305
|
// The renderer reads the SDK payload directly; there is no longer a
|
|
@@ -400,6 +434,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
400
434
|
text: args.text,
|
|
401
435
|
title: args.title ?? defaultTitle(args.text),
|
|
402
436
|
...memoryOnly,
|
|
437
|
+
database: args.database,
|
|
438
|
+
collection: args.collection,
|
|
403
439
|
// Default stays true. The SDK retries POSTs, so upsert is what keeps a
|
|
404
440
|
// retried ingest from duplicating — flipping this default would trade a
|
|
405
441
|
// silent overwrite for a silent duplicate.
|
|
@@ -433,6 +469,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
433
469
|
isMarkdown: opts?.isMarkdown,
|
|
434
470
|
customInstructions: INGEST_INSTRUCTIONS,
|
|
435
471
|
upsert: opts?.overwrite ?? true,
|
|
472
|
+
database: opts?.database,
|
|
473
|
+
collection: opts?.collection,
|
|
436
474
|
}, { signal });
|
|
437
475
|
const res = toAddMemoryResponse(raw);
|
|
438
476
|
const conversationId = createdId(res) ?? sourceId;
|
|
@@ -484,6 +522,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
484
522
|
ids: args.source_ids,
|
|
485
523
|
page: args.page,
|
|
486
524
|
pageSize: args.page_size,
|
|
525
|
+
database: args.database,
|
|
526
|
+
collection: args.collection,
|
|
487
527
|
}, { signal });
|
|
488
528
|
const { memories, page } = toMemoryList(raw);
|
|
489
529
|
if (memories.length === 0) {
|
|
@@ -532,6 +572,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
532
572
|
ids: args.source_ids,
|
|
533
573
|
page: args.page,
|
|
534
574
|
pageSize: args.page_size,
|
|
575
|
+
database: args.database,
|
|
576
|
+
collection: args.collection,
|
|
535
577
|
}, { signal });
|
|
536
578
|
const { sources, page } = toSourceList(raw);
|
|
537
579
|
if (sources.length === 0) {
|
|
@@ -671,6 +713,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
671
713
|
offset: a.offset,
|
|
672
714
|
limit: a.limit,
|
|
673
715
|
expiry_seconds: a.expiry_seconds,
|
|
716
|
+
database: a.database,
|
|
717
|
+
collection: a.collection,
|
|
674
718
|
};
|
|
675
719
|
}
|
|
676
720
|
async function runInspect(args, signal) {
|
|
@@ -679,6 +723,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
679
723
|
id: args.source_id,
|
|
680
724
|
mode: args.mode ?? "content",
|
|
681
725
|
expirySeconds: args.expiry_seconds,
|
|
726
|
+
database: args.database,
|
|
727
|
+
collection: args.collection,
|
|
682
728
|
}, { signal });
|
|
683
729
|
// Soft failure: return a normal (non-error) text result, matching v1.
|
|
684
730
|
if (!res.success || res.error) {
|
|
@@ -701,7 +747,11 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
701
747
|
}
|
|
702
748
|
async function runStatus(args, signal) {
|
|
703
749
|
logger.debug(`${TOOL_NAMES.STATUS}: ${args.ids.join(", ")}`);
|
|
704
|
-
const res = await hydra.context.ingestionStatus({
|
|
750
|
+
const res = await hydra.context.ingestionStatus({
|
|
751
|
+
ids: args.ids,
|
|
752
|
+
database: args.database,
|
|
753
|
+
collection: args.collection,
|
|
754
|
+
}, { signal });
|
|
705
755
|
const statuses = res.statuses ?? [];
|
|
706
756
|
if (statuses.length === 0) {
|
|
707
757
|
return textResult(`No indexing status found for: ${args.ids.join(", ")}. ` +
|
|
@@ -812,12 +862,17 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
812
862
|
throw new Error(`${TOOL_NAMES.DELETE} requires \`ids\` (or \`id\`). Ids come from ` +
|
|
813
863
|
`${TOOL_NAMES.QUERY} or ${TOOL_NAMES.LIST} — do not guess one.`);
|
|
814
864
|
}
|
|
815
|
-
return { ids, kind: a.kind };
|
|
865
|
+
return { ids, kind: a.kind, database: a.database, collection: a.collection };
|
|
816
866
|
}
|
|
817
867
|
async function runDelete(args, signal) {
|
|
818
868
|
const kind = args.kind ?? "memory";
|
|
819
869
|
logger.debug(`${TOOL_NAMES.DELETE}: ${kind} ${args.ids.join(", ")}`);
|
|
820
|
-
const res = await hydra.context.delete({
|
|
870
|
+
const res = await hydra.context.delete({
|
|
871
|
+
ids: args.ids,
|
|
872
|
+
kind,
|
|
873
|
+
database: args.database,
|
|
874
|
+
collection: args.collection,
|
|
875
|
+
}, { signal });
|
|
821
876
|
// `userMemoryDeleted` is a COUNT on the v2 wire — a live delete returned
|
|
822
877
|
// `{"deletedCount":1,"userMemoryDeleted":1}` — and the SDK types it as a
|
|
823
878
|
// number. The v1 memory-delete handler returns a boolean for the same
|
|
@@ -843,6 +898,183 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
843
898
|
}
|
|
844
899
|
return deleteReport(kind, args.ids, res, removed, removedCount);
|
|
845
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
|
+
}
|
|
846
1078
|
// --- Registration helper ---
|
|
847
1079
|
function register(name, inputSchema, handler, annotations, outputSchema) {
|
|
848
1080
|
const desc = TOOL_DESCRIPTIONS[name];
|
|
@@ -863,6 +1095,18 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
863
1095
|
}, wrapped);
|
|
864
1096
|
}
|
|
865
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
|
+
};
|
|
866
1110
|
const querySchema = {
|
|
867
1111
|
query: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.query),
|
|
868
1112
|
kind: z
|
|
@@ -907,6 +1151,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
907
1151
|
.max(5)
|
|
908
1152
|
.optional()
|
|
909
1153
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.num_related_chunks),
|
|
1154
|
+
...scopeSchema,
|
|
910
1155
|
};
|
|
911
1156
|
const storeSchema = {
|
|
912
1157
|
text: z
|
|
@@ -935,6 +1180,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
935
1180
|
.boolean()
|
|
936
1181
|
.optional()
|
|
937
1182
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.overwrite),
|
|
1183
|
+
...scopeSchema,
|
|
938
1184
|
};
|
|
939
1185
|
const ingestMetadataSchema = {
|
|
940
1186
|
metadata: z
|
|
@@ -943,6 +1189,11 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
943
1189
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.metadata),
|
|
944
1190
|
observation_date: z
|
|
945
1191
|
.string()
|
|
1192
|
+
.regex(OBSERVATION_DATE_PATTERN, {
|
|
1193
|
+
message: "observation_date must be a calendar date as YYYY-MM-DD (e.g. 2026-07-04); " +
|
|
1194
|
+
"a date-time is accepted and kept as its date part",
|
|
1195
|
+
})
|
|
1196
|
+
.transform((value) => value.slice(0, CALENDAR_DATE_LENGTH))
|
|
946
1197
|
.optional()
|
|
947
1198
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST].params.observation_date),
|
|
948
1199
|
};
|
|
@@ -991,6 +1242,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
991
1242
|
.string()
|
|
992
1243
|
.optional()
|
|
993
1244
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.user_name),
|
|
1245
|
+
...scopeSchema,
|
|
994
1246
|
};
|
|
995
1247
|
const listSchema = {
|
|
996
1248
|
// Required, not defaulted. `hydradb_list({})` used to return memories only
|
|
@@ -1021,12 +1273,17 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1021
1273
|
.max(100)
|
|
1022
1274
|
.optional()
|
|
1023
1275
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.page_size),
|
|
1276
|
+
...scopeSchema,
|
|
1024
1277
|
};
|
|
1025
1278
|
const listSourcesSchema = {
|
|
1026
1279
|
source_ids: z
|
|
1027
1280
|
.array(z.string())
|
|
1028
1281
|
.optional()
|
|
1029
1282
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES].params.source_ids),
|
|
1283
|
+
...scopeSchema,
|
|
1284
|
+
};
|
|
1285
|
+
const listMemoriesSchema = {
|
|
1286
|
+
...scopeSchema,
|
|
1030
1287
|
};
|
|
1031
1288
|
const inspectSchema = {
|
|
1032
1289
|
// CONTRACT §1 says a source's identifier field is `id`, but this surface
|
|
@@ -1064,6 +1321,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1064
1321
|
.min(1)
|
|
1065
1322
|
.optional()
|
|
1066
1323
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.expiry_seconds),
|
|
1324
|
+
...scopeSchema,
|
|
1067
1325
|
};
|
|
1068
1326
|
const deleteSchema = {
|
|
1069
1327
|
ids: z
|
|
@@ -1079,6 +1337,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1079
1337
|
.enum(["memory", "knowledge"])
|
|
1080
1338
|
.optional()
|
|
1081
1339
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.kind),
|
|
1340
|
+
...scopeSchema,
|
|
1082
1341
|
};
|
|
1083
1342
|
// Output schemas, declared only where the result is genuinely structured.
|
|
1084
1343
|
// Query stays prose: its payload IS text, and forcing it into fields would
|
|
@@ -1117,11 +1376,78 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1117
1376
|
.array(z.string())
|
|
1118
1377
|
.min(1)
|
|
1119
1378
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STATUS].params.ids),
|
|
1379
|
+
...scopeSchema,
|
|
1120
1380
|
};
|
|
1121
1381
|
const deleteMemorySchema = {
|
|
1122
1382
|
memory_id: z
|
|
1123
1383
|
.string()
|
|
1124
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(),
|
|
1125
1451
|
};
|
|
1126
1452
|
// `destructiveHint` was missing from the annotations type, so no tool could
|
|
1127
1453
|
// declare it — and the MCP spec defaults it to TRUE for any non-readonly
|
|
@@ -1201,6 +1527,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1201
1527
|
title: a.title,
|
|
1202
1528
|
isMarkdown: a.is_markdown,
|
|
1203
1529
|
overwrite: a.overwrite,
|
|
1530
|
+
database: a.database,
|
|
1531
|
+
collection: a.collection,
|
|
1204
1532
|
}, extra?.signal);
|
|
1205
1533
|
}
|
|
1206
1534
|
if (a.text != null) {
|
|
@@ -1214,6 +1542,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1214
1542
|
overwrite: a.overwrite,
|
|
1215
1543
|
metadata: a.metadata,
|
|
1216
1544
|
observation_date: a.observation_date,
|
|
1545
|
+
database: a.database,
|
|
1546
|
+
collection: a.collection,
|
|
1217
1547
|
}, extra?.signal);
|
|
1218
1548
|
}
|
|
1219
1549
|
throw new Error(`${TOOL_NAMES.INGEST} requires either \`text\` (a note) or \`turns\` (a conversation).`);
|
|
@@ -1241,13 +1571,49 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1241
1571
|
}
|
|
1242
1572
|
const ids = a.ids ?? a.source_ids;
|
|
1243
1573
|
if (a.kind === "knowledge") {
|
|
1244
|
-
return runListSources({
|
|
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);
|
|
1245
1581
|
}
|
|
1246
|
-
return runListMemories({
|
|
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);
|
|
1247
1589
|
}, readOnly, listOutputSchema);
|
|
1248
1590
|
register(TOOL_NAMES.INSPECT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
|
|
1249
1591
|
register(TOOL_NAMES.DELETE, deleteSchema, (args, extra) => runDelete(toDeleteArgs(args), extra?.signal), destructive, deleteOutputSchema);
|
|
1250
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
|
+
}
|
|
1251
1617
|
// --- Deprecated aliases ---
|
|
1252
1618
|
//
|
|
1253
1619
|
// Registered only when HYDRADB_MCP_LEGACY_TOOLS is set. Off by default.
|
|
@@ -1276,14 +1642,23 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1276
1642
|
const a = args;
|
|
1277
1643
|
// The deprecated alias keeps its historical shape (user_name only; infer
|
|
1278
1644
|
// on, no title/markdown). The canonical hydradb_ingest forwards the rest.
|
|
1279
|
-
return runIngestConversation(a.turns, a.source_id, {
|
|
1645
|
+
return runIngestConversation(a.turns, a.source_id, {
|
|
1646
|
+
userName: a.user_name,
|
|
1647
|
+
database: a.database,
|
|
1648
|
+
collection: a.collection,
|
|
1649
|
+
}, extra?.signal);
|
|
1280
1650
|
}, additiveWrite);
|
|
1281
|
-
register(TOOL_NAMES.LIST_MEMORIES,
|
|
1651
|
+
register(TOOL_NAMES.LIST_MEMORIES, listMemoriesSchema, (args, extra) => runListMemories(args, extra?.signal), readOnly);
|
|
1282
1652
|
register(TOOL_NAMES.LIST_SOURCES, listSourcesSchema, (args, extra) => runListSources(args, extra?.signal), readOnly);
|
|
1283
1653
|
register(TOOL_NAMES.FETCH_CONTENT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
|
|
1284
1654
|
register(TOOL_NAMES.DELETE_MEMORY, deleteMemorySchema, (args, extra) => {
|
|
1285
|
-
const
|
|
1286
|
-
return runDelete({
|
|
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);
|
|
1287
1662
|
}, destructive);
|
|
1288
1663
|
}
|
|
1289
1664
|
return server.server;
|