@hydradb/mcp 1.2.1 → 1.3.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/CHANGELOG.md +141 -0
- package/README.md +185 -0
- 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 +60 -0
- package/dist/descriptions.js +159 -1
- package/dist/descriptions.js.map +1 -1
- package/dist/http-config.d.ts +190 -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 +427 -0
- package/dist/http.js.map +1 -0
- package/dist/hydra/client.d.ts +69 -4
- package/dist/hydra/client.js +84 -19
- 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 +4 -1
- package/dist/hydra/index.js +3 -1
- package/dist/hydra/index.js.map +1 -1
- package/dist/oauth.d.ts +109 -0
- package/dist/oauth.js +228 -0
- package/dist/oauth.js.map +1 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.js +440 -12
- package/dist/server.js.map +1 -1
- package/dist/tool-names.d.ts +13 -0
- package/dist/tool-names.js +26 -0
- package/dist/tool-names.js.map +1 -1
- package/package.json +10 -2
package/dist/server.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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
|
-
import { HydraDB } from "./hydra/index.js";
|
|
11
|
+
import { assertCollectionAllowed, assertDatabaseAllowed, HydraDB } from "./hydra/index.js";
|
|
10
12
|
import { logger } from "./logger.js";
|
|
11
13
|
import { ALIAS_REPLACEMENTS, DEPRECATED_TOOL_NAMES, TOOL_NAMES } from "./tool-names.js";
|
|
12
14
|
// Host-owned default: silently attached to ingest so Hydra DB extracts the kind
|
|
@@ -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, options = {}) {
|
|
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({
|
|
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({
|
|
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,257 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
865
898
|
}
|
|
866
899
|
return deleteReport(kind, args.ids, res, removed, removedCount);
|
|
867
900
|
}
|
|
901
|
+
/**
|
|
902
|
+
* Which databases this connection can address, and which is the default.
|
|
903
|
+
*
|
|
904
|
+
* A confined connection answers from its allowed list without a network
|
|
905
|
+
* call: the list IS the answer, and asking the API would only show
|
|
906
|
+
* databases the connection is not permitted to use.
|
|
907
|
+
*/
|
|
908
|
+
async function runDatabases() {
|
|
909
|
+
const defaultDatabase = hydra.database;
|
|
910
|
+
let databases;
|
|
911
|
+
let confined = false;
|
|
912
|
+
if (hydra.allowedDatabases) {
|
|
913
|
+
databases = [...hydra.allowedDatabases];
|
|
914
|
+
confined = true;
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
const listed = await hydra.databases.list();
|
|
918
|
+
databases = (listed.databases ?? listed.tenantIds ?? []).filter(Boolean);
|
|
919
|
+
}
|
|
920
|
+
if (!databases.includes(defaultDatabase))
|
|
921
|
+
databases.unshift(defaultDatabase);
|
|
922
|
+
const lines = databases.map((d) => ` - ${d}${d === defaultDatabase ? " (default for this connection)" : ""}`);
|
|
923
|
+
const note = confined
|
|
924
|
+
? "\nThis connection is confined to the database(s) above; any other name is refused. " +
|
|
925
|
+
"The user chose this when approving the connection."
|
|
926
|
+
: "\nPass `database` on any tool to work in another one.";
|
|
927
|
+
return structuredResult(`${databases.length} database(s):\n${lines.join("\n")}${note}`, { databases, default: defaultDatabase, confined });
|
|
928
|
+
}
|
|
929
|
+
// --- BYOG graph handlers ---
|
|
930
|
+
/**
|
|
931
|
+
* The scope a graph call runs against.
|
|
932
|
+
*
|
|
933
|
+
* Resolved per call rather than captured once, so a caller can address a
|
|
934
|
+
* second graph without reconfiguring the server. The collection name is
|
|
935
|
+
* validated here because the server's rule is a documented charset and
|
|
936
|
+
* rejecting locally names it, where the remote failure is a bare 400.
|
|
937
|
+
*/
|
|
938
|
+
/**
|
|
939
|
+
* The graph database a call runs against, checked against the connection's
|
|
940
|
+
* allowed set. The graph client does not go through the context client's
|
|
941
|
+
* scope(), so the same rule is applied here for every graph tool.
|
|
942
|
+
*/
|
|
943
|
+
function graphDatabase(override) {
|
|
944
|
+
const database = override?.trim() || graphConfig.database;
|
|
945
|
+
if (database && database !== hydra.database) {
|
|
946
|
+
assertDatabaseAllowed(database, hydra.allowedDatabases);
|
|
947
|
+
}
|
|
948
|
+
return database;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* The graph collection a call runs against, checked the same way the
|
|
952
|
+
* database is. `drop_collection` makes an unchecked override destructive,
|
|
953
|
+
* so confinement covers both axes or it covers nothing.
|
|
954
|
+
*/
|
|
955
|
+
function graphCollection(override) {
|
|
956
|
+
const collection = override?.trim() || graphConfig.collection;
|
|
957
|
+
if (collection && collection !== hydra.collection) {
|
|
958
|
+
assertCollectionAllowed(collection, hydra.allowedCollections);
|
|
959
|
+
}
|
|
960
|
+
return collection;
|
|
961
|
+
}
|
|
962
|
+
function graphScope(args) {
|
|
963
|
+
const database = graphDatabase(args.database);
|
|
964
|
+
const collection = graphCollection(args.collection);
|
|
965
|
+
if (!database) {
|
|
966
|
+
throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
|
|
967
|
+
"or pass `database` on this call.");
|
|
968
|
+
}
|
|
969
|
+
if (!COLLECTION_PATTERN.test(collection)) {
|
|
970
|
+
throw new Error(`Invalid graph collection name "${collection}". Collection names must match ` +
|
|
971
|
+
"[A-Za-z0-9][A-Za-z0-9_-]{0,63} — start with a letter or digit, then letters, " +
|
|
972
|
+
"digits, underscores or hyphens, up to 64 characters.");
|
|
973
|
+
}
|
|
974
|
+
return { database, collection };
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Refuse an oversized request before it goes out.
|
|
978
|
+
*
|
|
979
|
+
* The server answers a body over 256 KiB with 413, but only after receiving
|
|
980
|
+
* all of it — so on the bulk loads where this actually happens, the remote
|
|
981
|
+
* check is the slowest possible way to learn the batch was too big. Measured
|
|
982
|
+
* in BYTES, not characters: the cap is on the encoded body, and non-ASCII
|
|
983
|
+
* property values are where a "small enough" batch stops being one.
|
|
984
|
+
*/
|
|
985
|
+
function assertBodyFits(body) {
|
|
986
|
+
let bytes;
|
|
987
|
+
try {
|
|
988
|
+
// The WHOLE body, not just the caller's two fields. `database` and
|
|
989
|
+
// `collection` are serialised alongside the query, so measuring
|
|
990
|
+
// without them let a payload sitting just under the cap pass here and
|
|
991
|
+
// be rejected remotely with a 413 — after the entire thing had been
|
|
992
|
+
// uploaded, which is the outcome this check exists to avoid.
|
|
993
|
+
bytes = Buffer.byteLength(JSON.stringify(body) ?? "", "utf8");
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
throw new Error("`params` could not be serialised to JSON — it must contain only plain " +
|
|
997
|
+
"values (strings, numbers, booleans, null, arrays, objects).");
|
|
998
|
+
}
|
|
999
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
1000
|
+
throw new Error(`This request is ${Math.round(bytes / 1024)} KiB, over Hydra DB's ` +
|
|
1001
|
+
`${MAX_BODY_BYTES / 1024} KiB limit. Split it into batches — send rows in ` +
|
|
1002
|
+
"chunks with `UNWIND $rows AS row ...` (about 500 rows per call is a good start).");
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* The single Cypher entry point: reads and writes go through one tool.
|
|
1007
|
+
*
|
|
1008
|
+
* This deliberately does NOT inspect the query. An earlier version lexed it
|
|
1009
|
+
* to classify reads vs writes and to pre-reject constructs the server
|
|
1010
|
+
* refuses; both were a client reimplementing the server's rules, able only
|
|
1011
|
+
* to agree with it or to be wrong. The server rejects unsupported
|
|
1012
|
+
* constructs before executing anything and says so more precisely than we
|
|
1013
|
+
* did, so the query goes out as written.
|
|
1014
|
+
*/
|
|
1015
|
+
async function runGraphCypher(args, signal) {
|
|
1016
|
+
const scope = graphScope(args);
|
|
1017
|
+
assertBodyFits({ ...scope, query: args.query, params: args.params });
|
|
1018
|
+
logger.debug(`${TOOL_NAMES.GRAPH_QUERY}: ${scope.database}/${scope.collection}`);
|
|
1019
|
+
const rows = await hydra.graph.query({ ...scope, query: args.query, params: args.params }, { signal });
|
|
1020
|
+
// A write with no RETURN legitimately yields zero rows. Reporting that as
|
|
1021
|
+
// "no results" invites the caller to retry a write that already committed.
|
|
1022
|
+
if (rows.length === 0) {
|
|
1023
|
+
return structuredResult(
|
|
1024
|
+
// Zero rows means one of two things and this does not guess which:
|
|
1025
|
+
// a read that matched nothing, or a write with no RETURN clause.
|
|
1026
|
+
// Naming both keeps a caller from re-running a write that already
|
|
1027
|
+
// committed because the result "looked empty".
|
|
1028
|
+
`The query ran against ${scope.database}/${scope.collection} and returned ` +
|
|
1029
|
+
"0 rows. For a read that means nothing matched; for a write with no RETURN " +
|
|
1030
|
+
"clause it is the expected result and the write has been applied — do not " +
|
|
1031
|
+
"re-run it to check.", { database: scope.database, collection: scope.collection, rows: [], row_count: 0 });
|
|
1032
|
+
}
|
|
1033
|
+
const maxRows = args.max_rows ?? 100;
|
|
1034
|
+
const rendered = renderRows(rows, { maxRows });
|
|
1035
|
+
return structuredResult(`${rows.length} row(s) from ${scope.database}/${scope.collection}:\n\n${rendered}`, {
|
|
1036
|
+
database: scope.database,
|
|
1037
|
+
collection: scope.collection,
|
|
1038
|
+
// Bounded the same way the prose is: structured output is a different
|
|
1039
|
+
// encoding of the same answer, not a bypass of its limits.
|
|
1040
|
+
rows: rows.slice(0, maxRows),
|
|
1041
|
+
row_count: rows.length,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
async function runGraphCollections(args, signal) {
|
|
1045
|
+
const database = graphDatabase(args.database);
|
|
1046
|
+
if (!database) {
|
|
1047
|
+
throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
|
|
1048
|
+
"or pass `database` on this call.");
|
|
1049
|
+
}
|
|
1050
|
+
logger.debug(`${TOOL_NAMES.GRAPH_COLLECTIONS}: ${database}`);
|
|
1051
|
+
const collections = await hydra.graph.listCollections({ database }, { signal });
|
|
1052
|
+
if (collections.length === 0) {
|
|
1053
|
+
return structuredResult(`No graph collections in ${database} yet. Collections are created by their ` +
|
|
1054
|
+
`first write — run one with ${TOOL_NAMES.GRAPH_QUERY}.`, { database, collections: [], count: 0 });
|
|
1055
|
+
}
|
|
1056
|
+
return structuredResult(`${collections.length} graph collection(s) in ${database}:\n` +
|
|
1057
|
+
collections.map((name) => ` - ${name}`).join("\n"), { database, collections, count: collections.length });
|
|
1058
|
+
}
|
|
1059
|
+
async function runGraphAdmin(args, signal) {
|
|
1060
|
+
const database = graphDatabase(args.database);
|
|
1061
|
+
if (!database) {
|
|
1062
|
+
throw new Error("No graph database configured. Set HYDRADB_GRAPH_DATABASE (or HYDRADB_DATABASE), " +
|
|
1063
|
+
"or pass `database` on this call.");
|
|
1064
|
+
}
|
|
1065
|
+
logger.debug(`${TOOL_NAMES.GRAPH_ADMIN}: ${args.action} ${database}`);
|
|
1066
|
+
if (args.action === "create_database") {
|
|
1067
|
+
const res = await hydra.graph.createDatabase(database, { signal });
|
|
1068
|
+
return structuredResult(`Created graph database "${database}" (status: ${res.status ?? "ready"}). ` +
|
|
1069
|
+
"Collections are created by their first write; there is no create-collection step.", { action: args.action, database, status: res.status ?? "ready", created: true });
|
|
1070
|
+
}
|
|
1071
|
+
if (args.action === "drop_collection") {
|
|
1072
|
+
const collection = args.collection?.trim();
|
|
1073
|
+
if (!collection) {
|
|
1074
|
+
throw new Error(`${TOOL_NAMES.GRAPH_ADMIN} action "drop_collection" requires \`collection\` — ` +
|
|
1075
|
+
"the name of the graph to drop. Nothing was deleted.");
|
|
1076
|
+
}
|
|
1077
|
+
// This action takes its collection directly rather than through
|
|
1078
|
+
// graphCollection(), because there is no default to fall back to, so
|
|
1079
|
+
// the confinement check has to be stated here as well. It is the one
|
|
1080
|
+
// place an unchecked collection would be irreversible.
|
|
1081
|
+
if (collection !== hydra.collection) {
|
|
1082
|
+
assertCollectionAllowed(collection, hydra.allowedCollections);
|
|
1083
|
+
}
|
|
1084
|
+
await hydra.graph.dropCollection({ database, collection }, { signal });
|
|
1085
|
+
// The endpoint is idempotent and does not report whether anything was
|
|
1086
|
+
// there, so this states what was requested rather than claiming a
|
|
1087
|
+
// removal that may not have had anything to remove.
|
|
1088
|
+
return structuredResult(`Dropped graph collection "${collection}" from ${database}, along with all its ` +
|
|
1089
|
+
"data. This call is idempotent, so it also succeeds when the collection did " +
|
|
1090
|
+
"not exist.", { action: args.action, database, collection, dropped: true });
|
|
1091
|
+
}
|
|
1092
|
+
if (args.action === "drop_database") {
|
|
1093
|
+
// This deletes EVERY graph collection in the database, so on a
|
|
1094
|
+
// collection-confined connection it cannot be performed within the
|
|
1095
|
+
// confinement: even an allowed database holds collections the user
|
|
1096
|
+
// never approved. The per-collection checks elsewhere cannot catch
|
|
1097
|
+
// this one, because the call names no collection at all. Refuse the
|
|
1098
|
+
// action outright rather than let the broadest destructive operation
|
|
1099
|
+
// be the way around the narrowest grant.
|
|
1100
|
+
if (hydra.allowedCollections) {
|
|
1101
|
+
throw new Error(`This connection is confined to collection ${hydra.allowedCollections
|
|
1102
|
+
.map((c) => `"${c}"`)
|
|
1103
|
+
.join(", ")}, and "drop_database" removes every collection in ` +
|
|
1104
|
+
`"${database}", including ones it was not granted. Nothing was deleted. ` +
|
|
1105
|
+
'Use "drop_collection" for a collection this connection may use, or ' +
|
|
1106
|
+
"reconnect with wider access.");
|
|
1107
|
+
}
|
|
1108
|
+
const res = await hydra.graph.dropDatabase(database, { signal });
|
|
1109
|
+
const dropped = res.deleted_collections ?? [];
|
|
1110
|
+
const listed = dropped.length > 0 ? ` Collections removed: ${dropped.join(", ")}.` : "";
|
|
1111
|
+
// Three outcomes, not two, and the third is "we were not told".
|
|
1112
|
+
//
|
|
1113
|
+
// `deleted: false` is a real, different result — the database predates
|
|
1114
|
+
// BYOG, so only its graph collections went and the database itself
|
|
1115
|
+
// remains. Reporting that as a full drop tells the user something is
|
|
1116
|
+
// gone that is still there.
|
|
1117
|
+
//
|
|
1118
|
+
// A MISSING `deleted` used to fall into the same branch as `true` and
|
|
1119
|
+
// claim a full drop. On a destructive, irreversible call that is the
|
|
1120
|
+
// wrong direction to guess in: the server did not establish that
|
|
1121
|
+
// outcome, so it is not asserted. Say what is known and how to check.
|
|
1122
|
+
let text;
|
|
1123
|
+
if (res.deleted === true) {
|
|
1124
|
+
text = `Dropped graph database "${database}" and everything in it.${listed}`;
|
|
1125
|
+
}
|
|
1126
|
+
else if (res.deleted === false) {
|
|
1127
|
+
text =
|
|
1128
|
+
`Dropped the graph collections in "${database}", but NOT the database itself — ` +
|
|
1129
|
+
"it was created through the standard database API, so remove it there." +
|
|
1130
|
+
listed;
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
text =
|
|
1134
|
+
`Dropped the graph collections in "${database}".${listed} The server did not ` +
|
|
1135
|
+
"report whether the database itself was removed, so that is unconfirmed — " +
|
|
1136
|
+
"check with your database listing rather than assuming it is gone.";
|
|
1137
|
+
}
|
|
1138
|
+
return structuredResult(text, {
|
|
1139
|
+
action: args.action,
|
|
1140
|
+
database,
|
|
1141
|
+
// Omitted rather than guessed when the server did not say, matching
|
|
1142
|
+
// how the memory delete path reports an unknown count.
|
|
1143
|
+
...(typeof res.deleted === "boolean"
|
|
1144
|
+
? { database_deleted: res.deleted }
|
|
1145
|
+
: { database_deleted_known: false }),
|
|
1146
|
+
deleted_collections: dropped,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
throw new Error(`${TOOL_NAMES.GRAPH_ADMIN} received an unknown action "${args.action}". ` +
|
|
1150
|
+
'Valid actions are "create_database", "drop_collection" and "drop_database".');
|
|
1151
|
+
}
|
|
868
1152
|
// --- Registration helper ---
|
|
869
1153
|
function register(name, inputSchema, handler, annotations, outputSchema) {
|
|
870
1154
|
const desc = TOOL_DESCRIPTIONS[name];
|
|
@@ -885,6 +1169,18 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
885
1169
|
}, wrapped);
|
|
886
1170
|
}
|
|
887
1171
|
// --- Input schemas ---
|
|
1172
|
+
const scopeSchema = {
|
|
1173
|
+
database: z
|
|
1174
|
+
.string()
|
|
1175
|
+
.min(1)
|
|
1176
|
+
.optional()
|
|
1177
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.database),
|
|
1178
|
+
collection: z
|
|
1179
|
+
.string()
|
|
1180
|
+
.min(1)
|
|
1181
|
+
.optional()
|
|
1182
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.collection),
|
|
1183
|
+
};
|
|
888
1184
|
const querySchema = {
|
|
889
1185
|
query: z.string().describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.query),
|
|
890
1186
|
kind: z
|
|
@@ -929,6 +1225,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
929
1225
|
.max(5)
|
|
930
1226
|
.optional()
|
|
931
1227
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.QUERY].params.num_related_chunks),
|
|
1228
|
+
...scopeSchema,
|
|
932
1229
|
};
|
|
933
1230
|
const storeSchema = {
|
|
934
1231
|
text: z
|
|
@@ -957,6 +1254,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
957
1254
|
.boolean()
|
|
958
1255
|
.optional()
|
|
959
1256
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STORE].params.overwrite),
|
|
1257
|
+
...scopeSchema,
|
|
960
1258
|
};
|
|
961
1259
|
const ingestMetadataSchema = {
|
|
962
1260
|
metadata: z
|
|
@@ -1018,6 +1316,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1018
1316
|
.string()
|
|
1019
1317
|
.optional()
|
|
1020
1318
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INGEST_CONVERSATION].params.user_name),
|
|
1319
|
+
...scopeSchema,
|
|
1021
1320
|
};
|
|
1022
1321
|
const listSchema = {
|
|
1023
1322
|
// Required, not defaulted. `hydradb_list({})` used to return memories only
|
|
@@ -1048,12 +1347,17 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1048
1347
|
.max(100)
|
|
1049
1348
|
.optional()
|
|
1050
1349
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST].params.page_size),
|
|
1350
|
+
...scopeSchema,
|
|
1051
1351
|
};
|
|
1052
1352
|
const listSourcesSchema = {
|
|
1053
1353
|
source_ids: z
|
|
1054
1354
|
.array(z.string())
|
|
1055
1355
|
.optional()
|
|
1056
1356
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.LIST_SOURCES].params.source_ids),
|
|
1357
|
+
...scopeSchema,
|
|
1358
|
+
};
|
|
1359
|
+
const listMemoriesSchema = {
|
|
1360
|
+
...scopeSchema,
|
|
1057
1361
|
};
|
|
1058
1362
|
const inspectSchema = {
|
|
1059
1363
|
// CONTRACT §1 says a source's identifier field is `id`, but this surface
|
|
@@ -1091,6 +1395,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1091
1395
|
.min(1)
|
|
1092
1396
|
.optional()
|
|
1093
1397
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.INSPECT].params.expiry_seconds),
|
|
1398
|
+
...scopeSchema,
|
|
1094
1399
|
};
|
|
1095
1400
|
const deleteSchema = {
|
|
1096
1401
|
ids: z
|
|
@@ -1106,6 +1411,7 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1106
1411
|
.enum(["memory", "knowledge"])
|
|
1107
1412
|
.optional()
|
|
1108
1413
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE].params.kind),
|
|
1414
|
+
...scopeSchema,
|
|
1109
1415
|
};
|
|
1110
1416
|
// Output schemas, declared only where the result is genuinely structured.
|
|
1111
1417
|
// Query stays prose: its payload IS text, and forcing it into fields would
|
|
@@ -1144,11 +1450,78 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1144
1450
|
.array(z.string())
|
|
1145
1451
|
.min(1)
|
|
1146
1452
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.STATUS].params.ids),
|
|
1453
|
+
...scopeSchema,
|
|
1147
1454
|
};
|
|
1148
1455
|
const deleteMemorySchema = {
|
|
1149
1456
|
memory_id: z
|
|
1150
1457
|
.string()
|
|
1151
1458
|
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.DELETE_MEMORY].params.memory_id),
|
|
1459
|
+
...scopeSchema,
|
|
1460
|
+
};
|
|
1461
|
+
// --- BYOG graph schemas ---
|
|
1462
|
+
const graphScopeSchema = {
|
|
1463
|
+
database: z
|
|
1464
|
+
.string()
|
|
1465
|
+
.min(1)
|
|
1466
|
+
.optional()
|
|
1467
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.database),
|
|
1468
|
+
collection: z
|
|
1469
|
+
.string()
|
|
1470
|
+
.min(1)
|
|
1471
|
+
.optional()
|
|
1472
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.collection),
|
|
1473
|
+
};
|
|
1474
|
+
/**
|
|
1475
|
+
* Cypher text is bounded like every other free-text input on this server.
|
|
1476
|
+
* The 256 KiB body cap covers query plus params together and is checked in
|
|
1477
|
+
* the handler; this only stops an absurd query string before it gets there.
|
|
1478
|
+
*/
|
|
1479
|
+
const MAX_CYPHER_CHARS = 100000;
|
|
1480
|
+
const graphCypherSchema = {
|
|
1481
|
+
query: z
|
|
1482
|
+
.string()
|
|
1483
|
+
.min(1, { message: "query must not be empty" })
|
|
1484
|
+
.max(MAX_CYPHER_CHARS, {
|
|
1485
|
+
message: `query must be at most ${MAX_CYPHER_CHARS} characters`,
|
|
1486
|
+
})
|
|
1487
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.query),
|
|
1488
|
+
params: z
|
|
1489
|
+
.record(z.unknown())
|
|
1490
|
+
.optional()
|
|
1491
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.params),
|
|
1492
|
+
...graphScopeSchema,
|
|
1493
|
+
max_rows: z
|
|
1494
|
+
.number()
|
|
1495
|
+
.int()
|
|
1496
|
+
.min(1)
|
|
1497
|
+
.max(1000)
|
|
1498
|
+
.optional()
|
|
1499
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_QUERY].params.max_rows),
|
|
1500
|
+
};
|
|
1501
|
+
const graphCollectionsSchema = {
|
|
1502
|
+
database: z
|
|
1503
|
+
.string()
|
|
1504
|
+
.optional()
|
|
1505
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_COLLECTIONS].params.database),
|
|
1506
|
+
};
|
|
1507
|
+
const graphAdminSchema = {
|
|
1508
|
+
action: z
|
|
1509
|
+
.enum(["create_database", "drop_collection", "drop_database"])
|
|
1510
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_ADMIN].params.action),
|
|
1511
|
+
database: z
|
|
1512
|
+
.string()
|
|
1513
|
+
.optional()
|
|
1514
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_ADMIN].params.database),
|
|
1515
|
+
collection: z
|
|
1516
|
+
.string()
|
|
1517
|
+
.optional()
|
|
1518
|
+
.describe(TOOL_DESCRIPTIONS[TOOL_NAMES.GRAPH_ADMIN].params.collection),
|
|
1519
|
+
};
|
|
1520
|
+
const graphRowsOutputSchema = {
|
|
1521
|
+
database: z.string(),
|
|
1522
|
+
collection: z.string(),
|
|
1523
|
+
rows: z.array(z.record(z.unknown())),
|
|
1524
|
+
row_count: z.number(),
|
|
1152
1525
|
};
|
|
1153
1526
|
// `destructiveHint` was missing from the annotations type, so no tool could
|
|
1154
1527
|
// declare it — and the MCP spec defaults it to TRUE for any non-readonly
|
|
@@ -1228,6 +1601,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1228
1601
|
title: a.title,
|
|
1229
1602
|
isMarkdown: a.is_markdown,
|
|
1230
1603
|
overwrite: a.overwrite,
|
|
1604
|
+
database: a.database,
|
|
1605
|
+
collection: a.collection,
|
|
1231
1606
|
}, extra?.signal);
|
|
1232
1607
|
}
|
|
1233
1608
|
if (a.text != null) {
|
|
@@ -1241,6 +1616,8 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1241
1616
|
overwrite: a.overwrite,
|
|
1242
1617
|
metadata: a.metadata,
|
|
1243
1618
|
observation_date: a.observation_date,
|
|
1619
|
+
database: a.database,
|
|
1620
|
+
collection: a.collection,
|
|
1244
1621
|
}, extra?.signal);
|
|
1245
1622
|
}
|
|
1246
1623
|
throw new Error(`${TOOL_NAMES.INGEST} requires either \`text\` (a note) or \`turns\` (a conversation).`);
|
|
@@ -1268,13 +1645,55 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1268
1645
|
}
|
|
1269
1646
|
const ids = a.ids ?? a.source_ids;
|
|
1270
1647
|
if (a.kind === "knowledge") {
|
|
1271
|
-
return runListSources({
|
|
1648
|
+
return runListSources({
|
|
1649
|
+
source_ids: ids,
|
|
1650
|
+
page: a.page,
|
|
1651
|
+
page_size: a.page_size,
|
|
1652
|
+
database: a.database,
|
|
1653
|
+
collection: a.collection,
|
|
1654
|
+
}, extra?.signal);
|
|
1272
1655
|
}
|
|
1273
|
-
return runListMemories({
|
|
1656
|
+
return runListMemories({
|
|
1657
|
+
source_ids: ids,
|
|
1658
|
+
page: a.page,
|
|
1659
|
+
page_size: a.page_size,
|
|
1660
|
+
database: a.database,
|
|
1661
|
+
collection: a.collection,
|
|
1662
|
+
}, extra?.signal);
|
|
1274
1663
|
}, readOnly, listOutputSchema);
|
|
1275
1664
|
register(TOOL_NAMES.INSPECT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
|
|
1276
1665
|
register(TOOL_NAMES.DELETE, deleteSchema, (args, extra) => runDelete(toDeleteArgs(args), extra?.signal), destructive, deleteOutputSchema);
|
|
1277
1666
|
register(TOOL_NAMES.STATUS, statusSchema, (args, extra) => runStatus(args, extra?.signal), readOnly);
|
|
1667
|
+
// Only an OAuth connection carries a user's database decision, and only
|
|
1668
|
+
// there does an agent need a way to see it. Registering this for API-key
|
|
1669
|
+
// connections too would change their tool list, which must stay identical.
|
|
1670
|
+
if (options.oauthTools) {
|
|
1671
|
+
register(TOOL_NAMES.DATABASES, {}, () => runDatabases(), readOnly);
|
|
1672
|
+
}
|
|
1673
|
+
// --- BYOG graph tools (PRO-1681) ---
|
|
1674
|
+
//
|
|
1675
|
+
// A separate product surface from the memory/knowledge tools above: property
|
|
1676
|
+
// graphs the user models and owns, addressed in Cypher. Registered by
|
|
1677
|
+
// default so the capability is discoverable — the feature exists today and
|
|
1678
|
+
// no client surfaces it — and gated by one switch:
|
|
1679
|
+
//
|
|
1680
|
+
// HYDRADB_MCP_GRAPH_TOOLS=0 withholds all three, for memory-only users
|
|
1681
|
+
// who do not want the manifest cost.
|
|
1682
|
+
//
|
|
1683
|
+
// There is no read-only mode. It would have to classify Cypher client-side
|
|
1684
|
+
// to decide what to refuse, which is a heuristic — offering it would invite
|
|
1685
|
+
// operators to trust a guarantee it could not make. Withholding the tools
|
|
1686
|
+
// outright is a real guarantee; that is the switch above.
|
|
1687
|
+
if (graphConfig.enabled) {
|
|
1688
|
+
register(TOOL_NAMES.GRAPH_QUERY, graphCypherSchema, (args, extra) => runGraphCypher(args, extra?.signal),
|
|
1689
|
+
// Destructive, not read-only: this one tool runs arbitrary Cypher, so
|
|
1690
|
+
// DELETE is as reachable through it as MATCH. Annotating it any other
|
|
1691
|
+
// way would tell a host it is safe to auto-approve, which is exactly
|
|
1692
|
+
// the claim the removed read/write split could not actually back.
|
|
1693
|
+
destructive, graphRowsOutputSchema);
|
|
1694
|
+
register(TOOL_NAMES.GRAPH_COLLECTIONS, graphCollectionsSchema, (args, extra) => runGraphCollections(args, extra?.signal), readOnly);
|
|
1695
|
+
register(TOOL_NAMES.GRAPH_ADMIN, graphAdminSchema, (args, extra) => runGraphAdmin(args, extra?.signal), destructive);
|
|
1696
|
+
}
|
|
1278
1697
|
// --- Deprecated aliases ---
|
|
1279
1698
|
//
|
|
1280
1699
|
// Registered only when HYDRADB_MCP_LEGACY_TOOLS is set. Off by default.
|
|
@@ -1303,14 +1722,23 @@ export function createHydraDBServer(hydraOverride) {
|
|
|
1303
1722
|
const a = args;
|
|
1304
1723
|
// The deprecated alias keeps its historical shape (user_name only; infer
|
|
1305
1724
|
// on, no title/markdown). The canonical hydradb_ingest forwards the rest.
|
|
1306
|
-
return runIngestConversation(a.turns, a.source_id, {
|
|
1725
|
+
return runIngestConversation(a.turns, a.source_id, {
|
|
1726
|
+
userName: a.user_name,
|
|
1727
|
+
database: a.database,
|
|
1728
|
+
collection: a.collection,
|
|
1729
|
+
}, extra?.signal);
|
|
1307
1730
|
}, additiveWrite);
|
|
1308
|
-
register(TOOL_NAMES.LIST_MEMORIES,
|
|
1731
|
+
register(TOOL_NAMES.LIST_MEMORIES, listMemoriesSchema, (args, extra) => runListMemories(args, extra?.signal), readOnly);
|
|
1309
1732
|
register(TOOL_NAMES.LIST_SOURCES, listSourcesSchema, (args, extra) => runListSources(args, extra?.signal), readOnly);
|
|
1310
1733
|
register(TOOL_NAMES.FETCH_CONTENT, inspectSchema, (args, extra) => runInspect(toInspectArgs(args), extra?.signal), readOnly);
|
|
1311
1734
|
register(TOOL_NAMES.DELETE_MEMORY, deleteMemorySchema, (args, extra) => {
|
|
1312
|
-
const
|
|
1313
|
-
return runDelete({
|
|
1735
|
+
const a = args;
|
|
1736
|
+
return runDelete({
|
|
1737
|
+
ids: [a.memory_id],
|
|
1738
|
+
kind: "memory",
|
|
1739
|
+
database: a.database,
|
|
1740
|
+
collection: a.collection,
|
|
1741
|
+
}, extra?.signal);
|
|
1314
1742
|
}, destructive);
|
|
1315
1743
|
}
|
|
1316
1744
|
return server.server;
|