@hydradb/mcp 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/README.md +170 -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 +55 -0
- package/dist/descriptions.js +150 -1
- 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 +21 -1
- package/dist/hydra/client.js +20 -12
- 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 +359 -11
- 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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,127 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.2.2] - 2026-08-19
|
|
11
|
+
|
|
12
|
+
### Added — per-call database and collection scope overrides
|
|
13
|
+
|
|
14
|
+
Every context tool (`hydradb_query`, `hydradb_ingest`, `hydradb_list`,
|
|
15
|
+
`hydradb_inspect`, `hydradb_delete`, `hydradb_status`, and deprecated aliases)
|
|
16
|
+
now accepts optional `database` and `collection` arguments directly in its payload.
|
|
17
|
+
This allows multi-tenant SaaS agents to dynamically switch tenant/collection scopes
|
|
18
|
+
per request within a single persistent MCP session, while falling back to the session
|
|
19
|
+
defaults when omitted.
|
|
20
|
+
|
|
21
|
+
### Added — remote HTTP transport (hosted server)
|
|
22
|
+
|
|
23
|
+
The server can now run as a long-lived **HTTP endpoint** that many clients reach
|
|
24
|
+
at one URL (`https://mcp.hydradb.com`), alongside the existing stdio (`npx`) binary.
|
|
25
|
+
This is what a hosted deployment runs, and what `npm run start:http` or the new
|
|
26
|
+
Docker image runs locally — nothing to install per user.
|
|
27
|
+
|
|
28
|
+
The tool surface is unchanged: the HTTP server builds the exact same server as
|
|
29
|
+
stdio via `createHydraDBServer`. Only connection and auth are new.
|
|
30
|
+
|
|
31
|
+
- **Root endpoint and backward-compatible alias.** The server serves the MCP
|
|
32
|
+
Streamable HTTP transport directly on `/` and `/mcp`.
|
|
33
|
+
- **Per-request, multi-tenant credentials.** A hosted process has no single
|
|
34
|
+
ambient account, so each request selects its tenant with headers —
|
|
35
|
+
`Authorization: Bearer <api-key>` (or `X-HydraDB-Api-Key`) and
|
|
36
|
+
`X-HydraDB-Database`, with optional `X-HydraDB-Collection` and
|
|
37
|
+
`X-HydraDB-Graph-*`. A request that authenticates nobody is refused (`401`);
|
|
38
|
+
one that names no database is `400`. A single-tenant self-host may instead set
|
|
39
|
+
`HYDRADB_API_KEY`/`HYDRADB_DATABASE` in the environment and clients send no
|
|
40
|
+
credentials — the header path falls back to it.
|
|
41
|
+
- **Safe-by-default network posture.** Binds loopback unless `BIND_ADDRESS` is
|
|
42
|
+
set; a `Host` allowlist (`ALLOWED_HOSTS`, loopback always) answers stray
|
|
43
|
+
authorities with `421`; CORS is closed until `ALLOWED_ORIGINS` lists an
|
|
44
|
+
origin. `Base URL`/timeout/retries stay operator-only env settings and are
|
|
45
|
+
never read from a request header.
|
|
46
|
+
- New: `src/http.ts` (Express app + lifecycle), `src/http-config.ts`
|
|
47
|
+
(operator + per-request config resolution), `Dockerfile`, `.dockerignore`,
|
|
48
|
+
`npm run start:http` / `dev:http`, and a `hydradb-mcp-http` bin. Runtime deps
|
|
49
|
+
`express` and `cors` were added.
|
|
50
|
+
|
|
51
|
+
### Added — graph (BYOG) tools
|
|
52
|
+
|
|
53
|
+
Hydra DB's **graph database** offering is now reachable from the MCP. Previously
|
|
54
|
+
this server exposed only the application offering (memory and knowledge); the
|
|
55
|
+
property graphs users model and own end to end, queried in Cypher, had no client
|
|
56
|
+
surface at all despite being [documented](https://docs.hydradb.com/essentials/v2/graph-collections-byog)
|
|
57
|
+
and live.
|
|
58
|
+
|
|
59
|
+
Three new tools, all additive — nothing about the existing six changed:
|
|
60
|
+
|
|
61
|
+
| Tool | Annotation | What it does |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `hydradb_graph_query` | `destructiveHint` | Cypher, reads and writes alike |
|
|
64
|
+
| `hydradb_graph_collections` | `readOnlyHint` | List the graphs in a graph database |
|
|
65
|
+
| `hydradb_graph_admin` | `destructiveHint` | Create a graph database; drop a collection or database |
|
|
66
|
+
|
|
67
|
+
This covers [Neo4j's MCP server](https://github.com/neo4j-contrib/mcp-neo4j)
|
|
68
|
+
`read_neo4j_cypher` and `write_neo4j_cypher`, plus the graph database lifecycle
|
|
69
|
+
its Aura server covers. Two of its capabilities are deliberately **not**
|
|
70
|
+
reproduced:
|
|
71
|
+
|
|
72
|
+
**One Cypher tool, not a read/write pair.** Neo4j splits `read_neo4j_cypher`
|
|
73
|
+
from `write_neo4j_cypher` so a host can auto-approve one and gate the other.
|
|
74
|
+
That split is only sound if the read/write classifier is right on every query,
|
|
75
|
+
and any classifier over Cypher text is a heuristic — Neo4j's own is a substring
|
|
76
|
+
scan that refuses `MATCH (p:Person) WHERE p.name = "CREATE something" RETURN
|
|
77
|
+
p.name`, a query HydraDB accepts and that mutates nothing. Rather than ship a
|
|
78
|
+
tool whose contract ("this one never writes") rests on a heuristic, there is one
|
|
79
|
+
tool, annotated destructive, and the host gates the whole graph surface. No
|
|
80
|
+
classifier ships at all.
|
|
81
|
+
|
|
82
|
+
**No schema tool.** Neo4j's `get_neo4j_schema` runs `CALL apoc.meta.schema()`,
|
|
83
|
+
which HydraDB rejects outright. A derived equivalent is not part of the product,
|
|
84
|
+
so none ships. Callers discover a collection's structure the same way they query
|
|
85
|
+
it — `MATCH (n) UNWIND labels(n) AS l RETURN l, count(*) AS c ORDER BY l` — and
|
|
86
|
+
the tool description says so.
|
|
87
|
+
|
|
88
|
+
Also worth recording: **`EXPLAIN` is not a preview.** `EXPLAIN MATCH (p:Person)
|
|
89
|
+
RETURN p` returns live rows rather than a plan, so it is documented as something
|
|
90
|
+
not to reach for.
|
|
91
|
+
|
|
92
|
+
The 256 KiB body cap IS enforced before upload, since the remote `413` arrives
|
|
93
|
+
only after the whole oversized batch has been sent — that is a transport fact
|
|
94
|
+
the client owns, not a rule about what Cypher means.
|
|
95
|
+
|
|
96
|
+
Registered by default, with one switch: `HYDRADB_MCP_GRAPH_TOOLS=0` withholds
|
|
97
|
+
all three, for memory-only users who do not want the extra tool definitions in
|
|
98
|
+
every conversation.
|
|
99
|
+
|
|
100
|
+
**The client does not inspect your Cypher.** No read/write classification, no
|
|
101
|
+
read-only mode, no local pre-rejection of unsupported constructs. All of those
|
|
102
|
+
would put a second, worse implementation of the server's rules inside a client,
|
|
103
|
+
able only to agree with the server or to be wrong — and being wrong means
|
|
104
|
+
refusing a query HydraDB would have run. The server rejects unsupported
|
|
105
|
+
constructs before executing anything (verified: a query mixing `CREATE` with a
|
|
106
|
+
procedure call leaves the node count unchanged) and its messages are more
|
|
107
|
+
specific than the ones this server used to produce. Queries are sent verbatim.
|
|
108
|
+
|
|
109
|
+
Withholding the tools is the only lockdown offered, because it is the only one
|
|
110
|
+
that is actually a guarantee.
|
|
111
|
+
|
|
112
|
+
New configuration: `HYDRADB_GRAPH_DATABASE` (falls back to `HYDRADB_DATABASE`)
|
|
113
|
+
and `HYDRADB_GRAPH_COLLECTION` (defaults to `default`). A graph database is a
|
|
114
|
+
different namespace from the memory database, so every graph tool also accepts
|
|
115
|
+
`database` and `collection` per call.
|
|
116
|
+
|
|
117
|
+
### Internal
|
|
118
|
+
|
|
119
|
+
- `HydraDB.graph` is a hand-rolled HTTP path, not an SDK call: `@hydradb/sdk`
|
|
120
|
+
at the pinned 2.1.2 has no `byog` resource, so the endpoints are unreachable
|
|
121
|
+
through it. It sits behind the same wrapper surface, unwraps the same envelope
|
|
122
|
+
by shape and raises the same `HydraWrapperError`, so callers cannot tell which
|
|
123
|
+
methods go through the SDK. When the SDK grows a `byog` resource, that one file
|
|
124
|
+
is replaced and nothing above it changes. The exact SDK pin is unaffected —
|
|
125
|
+
there is no generated name to be insulated from yet.
|
|
126
|
+
- `responseError()` added to the wrapper's error module so failures from the
|
|
127
|
+
non-SDK path are formatted through the same code as SDK failures, keeping the
|
|
128
|
+
error code, the server's message and the request id.
|
|
8
129
|
## [1.2.1] - 2026-08-17
|
|
9
130
|
|
|
10
131
|
### Fixed
|
package/README.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
MCP (Model Context Protocol) server for [Hydra DB](https://hydradb.com), the state-of-the-art agentic memory. Provides tools for storing, recalling, and managing memories with knowledge-graph enriched context.
|
|
4
4
|
|
|
5
|
+
Run it two ways, same tools either way:
|
|
6
|
+
|
|
7
|
+
- **Local (stdio)** — the `npx @hydradb/mcp` binary each client spawns. No server to operate; credentials live in the client's config. This is the default and everything below the [Configuration](#configuration) section documents it.
|
|
8
|
+
- **Remote (HTTP)** — one hosted process behind a URL like `https://mcp.hydradb.com` that many clients point at, with nothing to install. See [**Remote / hosted server**](#remote--hosted-server).
|
|
9
|
+
|
|
5
10
|
## Available Tools
|
|
6
11
|
|
|
7
12
|
| Tool | What it does |
|
|
@@ -16,6 +21,44 @@ MCP (Model Context Protocol) server for [Hydra DB](https://hydradb.com), the sta
|
|
|
16
21
|
Ids flow between these: `hydradb_query` and `hydradb_list` emit them;
|
|
17
22
|
`hydradb_inspect`, `hydradb_delete` and `hydradb_status` accept them.
|
|
18
23
|
|
|
24
|
+
### Graph tools (Cypher)
|
|
25
|
+
|
|
26
|
+
Hydra DB also runs **property graphs you model and own end to end**, queried in [Cypher](https://docs.hydradb.com/essentials/v2/graph-collections-byog). This is a different product surface from the memory and knowledge above, and nothing crosses between them: `hydradb_query` cannot see graph data, and `hydradb_graph_query` cannot see memories.
|
|
27
|
+
|
|
28
|
+
| Tool | What it does |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `hydradb_graph_query` | Run Cypher — reads **and** writes |
|
|
31
|
+
| `hydradb_graph_collections` | List the graphs in a graph database |
|
|
32
|
+
| `hydradb_graph_admin` | Create a graph database; drop a collection or a database |
|
|
33
|
+
|
|
34
|
+
`hydradb_graph_query` is annotated `destructiveHint`, because it runs arbitrary Cypher and `DELETE` is as reachable through it as `MATCH`. There is deliberately **no** separate read-only Cypher tool and **no read-only mode**: both would mean classifying Cypher text client-side to decide what to refuse, which is a heuristic — a promise the server can keep and a client cannot. This server does not inspect your query at all; it sends it and reports what HydraDB says. To lock the graph surface down, withhold the tools (below) — that is a real guarantee.
|
|
35
|
+
|
|
36
|
+
```jsonc
|
|
37
|
+
// Everyone Alice knows within four hops
|
|
38
|
+
{"query": "MATCH (a:Person {name:$n})-[:KNOWS*1..4]->(r) RETURN DISTINCT r.name AS name",
|
|
39
|
+
"params": {"n": "Alice"}}
|
|
40
|
+
|
|
41
|
+
// Bulk load, re-runnable after a failure
|
|
42
|
+
{"query": "UNWIND $rows AS row MERGE (p:Person {ext_id: row.ext_id}) SET p += row",
|
|
43
|
+
"params": {"rows": [{"ext_id": "a", "name": "Alice"}]}}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Differences from Neo4j** worth knowing before you write Cypher. Each is rejected *before* execution, so a rejected query changes nothing and fails identically on retry:
|
|
47
|
+
|
|
48
|
+
- Procedure calls (`CALL db.*`, `CALL apoc.*`) are rejected **by the server**, before it executes anything. `CALL { ... }` subqueries are fine. There is no schema tool and no `apoc.meta.schema()`; to learn a collection's structure, query it — `MATCH (n) UNWIND labels(n) AS l RETURN l, count(*) AS c ORDER BY l`.
|
|
49
|
+
- `LOAD CSV` is rejected — pass data through `params` instead.
|
|
50
|
+
- Existence checks are bare pattern predicates (`WHERE (p)-[:KNOWS]->()`); `EXISTS { ... }` and `exists()` are not accepted.
|
|
51
|
+
- `shortestPath` belongs in `RETURN`/`WITH`, not `MATCH p = ...`, and must be directed.
|
|
52
|
+
- `EXPLAIN`/`PROFILE` **execute** the query rather than planning it — do not use them to preview one.
|
|
53
|
+
|
|
54
|
+
Collections auto-create on first write, so there is no create-collection call. Requests are capped at 256 KiB (enforced locally, before upload) and large result sets are truncated server-side — paginate with `ORDER BY ... SKIP $offset LIMIT $limit`.
|
|
55
|
+
|
|
56
|
+
To turn the graph tools off entirely:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
HYDRADB_MCP_GRAPH_TOOLS=0 # withhold all three
|
|
60
|
+
```
|
|
61
|
+
|
|
19
62
|
### Deprecated aliases
|
|
20
63
|
|
|
21
64
|
The previous `hydra_db_*` tool names are **no longer registered by default** as
|
|
@@ -145,6 +188,14 @@ Checks whether ingested sources have finished indexing.
|
|
|
145
188
|
| `HYDRADB_TIMEOUT_SECONDS` | Per-attempt request timeout | `30` |
|
|
146
189
|
| `HYDRADB_MAX_RETRIES` | Retries per request (0 disables) | `2` |
|
|
147
190
|
| `HYDRADB_MCP_LEGACY_TOOLS` | Register the deprecated `hydra_db_*` tools | *off* |
|
|
191
|
+
| `HYDRADB_GRAPH_DATABASE` | Default graph database for the Cypher tools | `HYDRADB_DATABASE` |
|
|
192
|
+
| `HYDRADB_GRAPH_COLLECTION` | Default graph collection | `default` |
|
|
193
|
+
| `HYDRADB_MCP_GRAPH_TOOLS` | Register the graph tools (`0` withholds them) | *on* |
|
|
194
|
+
|
|
195
|
+
A graph database is a **different namespace** from the memory database: the same
|
|
196
|
+
name can exist as both, and Cypher aimed at the wrong one reads an empty graph
|
|
197
|
+
rather than failing. Every graph tool also takes `database` and `collection`
|
|
198
|
+
per call, overriding these defaults.
|
|
148
199
|
|
|
149
200
|
The legacy `HYDRA_DB_*` names — `HYDRA_DB_API_KEY`, `HYDRA_DB_TENANT_ID`,
|
|
150
201
|
`HYDRA_DB_SUB_TENANT_ID`, `HYDRA_DB_BASE_URL`, `HYDRA_DB_LOG_LEVEL` — remain
|
|
@@ -230,6 +281,118 @@ To partition data, set the `HYDRADB_COLLECTION` environment variable:
|
|
|
230
281
|
}
|
|
231
282
|
```
|
|
232
283
|
|
|
284
|
+
## Remote / hosted server
|
|
285
|
+
|
|
286
|
+
Everything above spawns the server locally over stdio. The same server also runs
|
|
287
|
+
as a long-lived **HTTP endpoint** that many clients reach at one URL — nothing to
|
|
288
|
+
install or update per user. This is what powers a hosted deployment like
|
|
289
|
+
`https://mcp.hydradb.com`, and what you run yourself with `npm run start:http`
|
|
290
|
+
or the Docker image.
|
|
291
|
+
|
|
292
|
+
The tool surface is identical; only how a client connects and authenticates
|
|
293
|
+
changes.
|
|
294
|
+
|
|
295
|
+
### Point a client at a URL
|
|
296
|
+
|
|
297
|
+
MCP clients that support a remote (`streamable-http`) server take a URL and
|
|
298
|
+
headers instead of a command:
|
|
299
|
+
|
|
300
|
+
```jsonc
|
|
301
|
+
{
|
|
302
|
+
"mcpServers": {
|
|
303
|
+
"hydradb": {
|
|
304
|
+
"url": "https://mcp.hydradb.com",
|
|
305
|
+
"headers": {
|
|
306
|
+
"Authorization": "Bearer YOUR_HYDRADB_API_KEY",
|
|
307
|
+
"X-HydraDB-Database": "your-database"
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Every request carries its own credentials, so one hosted process serves any
|
|
315
|
+
number of independent users. The headers a request may send:
|
|
316
|
+
|
|
317
|
+
| Header | Maps to | Required |
|
|
318
|
+
| --- | --- | --- |
|
|
319
|
+
| `Authorization: Bearer <key>` | Hydra DB API key (`X-HydraDB-Api-Key` also accepted) | Yes\* |
|
|
320
|
+
| `X-HydraDB-Database` | Default database (tenant scope) | Yes\* |
|
|
321
|
+
| `X-HydraDB-Collection` | Default collection (sub-tenant); defaults to `hydra-db-mcp` | No |
|
|
322
|
+
| `X-HydraDB-Graph-Database` | Default graph database for the Cypher tools; defaults to the request's database | No |
|
|
323
|
+
| `X-HydraDB-Graph-Collection` | Default graph collection; defaults to `default` | No |
|
|
324
|
+
|
|
325
|
+
\* Unless the server was started with `HYDRADB_API_KEY` / `HYDRADB_DATABASE` in
|
|
326
|
+
its environment (single-tenant self-host, below), in which case a request may
|
|
327
|
+
omit them and fall back to the server's own credentials. A request that supplies
|
|
328
|
+
neither a header nor a server-side default is refused: `401` with no key, `400`
|
|
329
|
+
with a key but no database.
|
|
330
|
+
|
|
331
|
+
Every tool additionally accepts optional `database` and `collection` parameters
|
|
332
|
+
directly in its arguments (e.g. `hydradb_query` with `{"query": "...", "database": "tenant_b"}`),
|
|
333
|
+
allowing multi-tenant agents to switch tenant scope per tool call while falling
|
|
334
|
+
back to the session defaults when omitted.
|
|
335
|
+
|
|
336
|
+
`Base URL`, request timeout and retry count are
|
|
337
|
+
**operator** settings read from the server's environment and are never taken
|
|
338
|
+
from a request header.
|
|
339
|
+
|
|
340
|
+
### Run the HTTP server
|
|
341
|
+
|
|
342
|
+
**Node:**
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
npm ci && npm run build
|
|
346
|
+
# Single-tenant: the server holds one account; clients send no credentials.
|
|
347
|
+
HYDRADB_API_KEY=your-key HYDRADB_DATABASE=your-database npm run start:http
|
|
348
|
+
# Multi-tenant: no account in the env; every client sends its own headers.
|
|
349
|
+
BIND_ADDRESS=0.0.0.0 ALLOWED_HOSTS=mcp.hydradb.com npm run start:http
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Docker:**
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
docker build -t hydradb-mcp .
|
|
356
|
+
# Single-tenant
|
|
357
|
+
docker run -p 8080:8080 -e HYDRADB_API_KEY=your-key -e HYDRADB_DATABASE=your-database hydradb-mcp
|
|
358
|
+
# Multi-tenant (clients authenticate per request)
|
|
359
|
+
docker run -p 8080:8080 -e ALLOWED_HOSTS=mcp.hydradb.com hydradb-mcp
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The primary MCP endpoint is `/` (with `/mcp` supported as an alias); `GET /health` is an unauthenticated liveness probe.
|
|
363
|
+
The image binds `0.0.0.0` inside the container (the host controls exposure with
|
|
364
|
+
`-p`) and runs as an unprivileged user.
|
|
365
|
+
|
|
366
|
+
### Server environment variables
|
|
367
|
+
|
|
368
|
+
These configure the HTTP process itself (the stdio server ignores them). All
|
|
369
|
+
the `HYDRADB_*` variables from [Environment Variables](#environment-variables)
|
|
370
|
+
also apply — as the single-tenant default and as operator settings.
|
|
371
|
+
|
|
372
|
+
| Variable | Description | Default |
|
|
373
|
+
| --- | --- | --- |
|
|
374
|
+
| `PORT` | Port to listen on | `8080` |
|
|
375
|
+
| `BIND_ADDRESS` | Interface to bind (`0.0.0.0` to accept off-host) | `127.0.0.1` |
|
|
376
|
+
| `ALLOWED_HOSTS` | Extra `Host` headers to accept (comma-separated); loopback always allowed | *(loopback only)* |
|
|
377
|
+
| `ALLOWED_ORIGINS` | CORS origins for browser clients (comma-separated; `*` allows any) | *(none)* |
|
|
378
|
+
| `TRUST_PROXY` | Express `trust proxy` when behind a reverse proxy: a hop count, `true`, or a subnet preset (`loopback`) | *off* |
|
|
379
|
+
|
|
380
|
+
### Security
|
|
381
|
+
|
|
382
|
+
The defaults are safe for local use and must be widened deliberately for a
|
|
383
|
+
public deployment — see [SECURITY.md](SECURITY.md):
|
|
384
|
+
|
|
385
|
+
- **Bind loopback by default.** `BIND_ADDRESS` stays `127.0.0.1` until you set
|
|
386
|
+
otherwise; `0.0.0.0` exposes the server on every interface and logs a warning.
|
|
387
|
+
- **Host allowlist.** Requests whose `Host` is not loopback or in `ALLOWED_HOSTS`
|
|
388
|
+
get `421 Misdirected Request` — a DNS-rebinding defence. Add your public
|
|
389
|
+
hostname when binding publicly.
|
|
390
|
+
- **CORS is closed by default.** No cross-origin browser request is accepted
|
|
391
|
+
until you list its origin in `ALLOWED_ORIGINS`. Non-browser clients (no
|
|
392
|
+
`Origin` header) are unaffected.
|
|
393
|
+
- **Terminate TLS in front.** Run the server behind a reverse proxy / load
|
|
394
|
+
balancer that handles HTTPS; do not expose plain HTTP to the internet.
|
|
395
|
+
|
|
233
396
|
## How It Works
|
|
234
397
|
|
|
235
398
|
The server talks to Hydra DB through the generated [`@hydradb/sdk`](https://www.npmjs.com/package/@hydradb/sdk)
|
|
@@ -257,6 +420,13 @@ For development with auto-reload:
|
|
|
257
420
|
HYDRADB_API_KEY=your-key HYDRADB_DATABASE=your-database npm run dev
|
|
258
421
|
```
|
|
259
422
|
|
|
423
|
+
To run the HTTP transport locally (see [Remote / hosted server](#remote--hosted-server)):
|
|
424
|
+
|
|
425
|
+
```bash
|
|
426
|
+
HYDRADB_API_KEY=your-key HYDRADB_DATABASE=your-database npm run dev:http
|
|
427
|
+
# then: curl localhost:8080/health
|
|
428
|
+
```
|
|
429
|
+
|
|
260
430
|
## Testing
|
|
261
431
|
|
|
262
432
|
```bash
|
package/dist/config.d.ts
CHANGED
|
@@ -14,9 +14,31 @@ export interface HydraDBConfig {
|
|
|
14
14
|
baseUrl?: string;
|
|
15
15
|
timeoutSeconds?: number;
|
|
16
16
|
maxRetries?: number;
|
|
17
|
+
graph: GraphConfig;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Scope and gating for the BYOG graph tools.
|
|
21
|
+
*
|
|
22
|
+
* A graph database is a DIFFERENT namespace from the memory database: the same
|
|
23
|
+
* name can exist as both, and a Cypher query aimed at the wrong one silently
|
|
24
|
+
* reads an empty graph rather than failing. So the graph scope is configured
|
|
25
|
+
* separately, and every graph tool also takes a per-call override.
|
|
26
|
+
*/
|
|
27
|
+
export interface GraphConfig {
|
|
28
|
+
/** Whether the graph tools are registered at all. */
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Default graph database. Falls back to HYDRADB_DATABASE — convenient when
|
|
32
|
+
* one name is used for both, and harmless otherwise because the tools accept
|
|
33
|
+
* `database` per call.
|
|
34
|
+
*/
|
|
35
|
+
database: string;
|
|
36
|
+
/** Default graph collection. */
|
|
37
|
+
collection: string;
|
|
17
38
|
}
|
|
18
39
|
export type EnvSource = Record<string, string | undefined>;
|
|
19
40
|
export type WarnFn = (message: string) => void;
|
|
41
|
+
export declare const DEFAULT_COLLECTION = "hydra-db-mcp";
|
|
20
42
|
/**
|
|
21
43
|
* Read a canonical `HYDRADB_*` variable, falling back to the legacy `HYDRA_DB_*`
|
|
22
44
|
* spelling with a once-per-process warning. Exported so every variable this
|
|
@@ -26,3 +48,21 @@ export type WarnFn = (message: string) => void;
|
|
|
26
48
|
*/
|
|
27
49
|
export declare function readEnv(env: EnvSource, canonical: string, deprecated: string, warn?: WarnFn): string | undefined;
|
|
28
50
|
export declare function resolveConfig(env?: EnvSource, warn?: WarnFn): HydraDBConfig;
|
|
51
|
+
/**
|
|
52
|
+
* Graph scope and gating, resolved on its own.
|
|
53
|
+
*
|
|
54
|
+
* Separate from `resolveConfig` because it must be resolvable WITHOUT an API key
|
|
55
|
+
* or a memory database: tests construct a server around an injected client and
|
|
56
|
+
* never set those, and making the graph tools depend on them would mean the
|
|
57
|
+
* graph surface could not be exercised without a full credential set.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resolveGraphConfig(env?: EnvSource, fallbackDatabase?: string): GraphConfig;
|
|
60
|
+
/**
|
|
61
|
+
* Numeric overrides are ignored rather than fatal when malformed.
|
|
62
|
+
*
|
|
63
|
+
* A typo'd timeout should not stop the server from starting — falling back to
|
|
64
|
+
* the built-in default keeps it running, and the alternative (exit 1 on a
|
|
65
|
+
* cosmetic env var) is worse than the misconfiguration.
|
|
66
|
+
*/
|
|
67
|
+
export declare function positiveInt(raw: string | undefined): number | undefined;
|
|
68
|
+
export declare function nonNegativeInt(raw: string | undefined): number | undefined;
|
package/dist/config.js
CHANGED
|
@@ -7,7 +7,26 @@
|
|
|
7
7
|
* deprecated alias emits exactly one stderr warning per process naming the
|
|
8
8
|
* canonical replacement.
|
|
9
9
|
*/
|
|
10
|
-
const
|
|
10
|
+
const DEFAULT_GRAPH_COLLECTION = "default";
|
|
11
|
+
/**
|
|
12
|
+
* Env flags accept the usual spellings, not just `1`.
|
|
13
|
+
*
|
|
14
|
+
* Someone writing `HYDRADB_MCP_GRAPH_TOOLS=false` means it, and silently
|
|
15
|
+
* ignoring that would register tools an operator believed they had withheld.
|
|
16
|
+
* An unrecognised value falls back rather than throwing, so a typo cannot stop
|
|
17
|
+
* the server starting.
|
|
18
|
+
*/
|
|
19
|
+
function flag(raw, fallback) {
|
|
20
|
+
if (raw == null || raw.trim() === "")
|
|
21
|
+
return fallback;
|
|
22
|
+
const value = raw.trim().toLowerCase();
|
|
23
|
+
if (["1", "true", "yes", "on"].includes(value))
|
|
24
|
+
return true;
|
|
25
|
+
if (["0", "false", "no", "off"].includes(value))
|
|
26
|
+
return false;
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
export const DEFAULT_COLLECTION = "hydra-db-mcp";
|
|
11
30
|
// Process-lifetime dedupe so each deprecated alias warns at most once.
|
|
12
31
|
const warnedOnce = new Set();
|
|
13
32
|
function defaultWarn(message) {
|
|
@@ -61,6 +80,24 @@ export function resolveConfig(env = process.env, warn = defaultWarn) {
|
|
|
61
80
|
baseUrl,
|
|
62
81
|
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
|
|
63
82
|
...(maxRetries != null ? { maxRetries } : {}),
|
|
83
|
+
graph: resolveGraphConfig(env, database),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Graph scope and gating, resolved on its own.
|
|
88
|
+
*
|
|
89
|
+
* Separate from `resolveConfig` because it must be resolvable WITHOUT an API key
|
|
90
|
+
* or a memory database: tests construct a server around an injected client and
|
|
91
|
+
* never set those, and making the graph tools depend on them would mean the
|
|
92
|
+
* graph surface could not be exercised without a full credential set.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveGraphConfig(env = process.env, fallbackDatabase = "") {
|
|
95
|
+
return {
|
|
96
|
+
enabled: flag(env.HYDRADB_MCP_GRAPH_TOOLS, true),
|
|
97
|
+
// Not `readEnv` — these are new names with no legacy spelling to alias,
|
|
98
|
+
// and inventing a deprecated one would be noise.
|
|
99
|
+
database: env.HYDRADB_GRAPH_DATABASE?.trim() || fallbackDatabase,
|
|
100
|
+
collection: env.HYDRADB_GRAPH_COLLECTION?.trim() || DEFAULT_GRAPH_COLLECTION,
|
|
64
101
|
};
|
|
65
102
|
}
|
|
66
103
|
/**
|
|
@@ -70,11 +107,11 @@ export function resolveConfig(env = process.env, warn = defaultWarn) {
|
|
|
70
107
|
* the built-in default keeps it running, and the alternative (exit 1 on a
|
|
71
108
|
* cosmetic env var) is worse than the misconfiguration.
|
|
72
109
|
*/
|
|
73
|
-
function positiveInt(raw) {
|
|
110
|
+
export function positiveInt(raw) {
|
|
74
111
|
const value = Number(raw);
|
|
75
112
|
return raw != null && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
76
113
|
}
|
|
77
|
-
function nonNegativeInt(raw) {
|
|
114
|
+
export function nonNegativeInt(raw) {
|
|
78
115
|
const value = Number(raw);
|
|
79
116
|
return raw != null && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
80
117
|
}
|
package/dist/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/cypher.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering and local limits for the BYOG (Bring Your Own Graph) tool.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately contains NO Cypher analysis. An earlier version lexed the query
|
|
5
|
+
* to classify reads vs writes and to pre-reject constructs the server refuses,
|
|
6
|
+
* which meant a second, worse implementation of the server's own rules living
|
|
7
|
+
* in a client: it could only ever agree with the server or be wrong, and being
|
|
8
|
+
* wrong meant refusing a query HydraDB would have run.
|
|
9
|
+
*
|
|
10
|
+
* The server is the authority on what Cypher is valid and permitted. It rejects
|
|
11
|
+
* unsupported constructs before executing anything — verified: a query mixing
|
|
12
|
+
* CREATE with a procedure call leaves the node count unchanged — and its
|
|
13
|
+
* messages are more specific than the ones this file used to produce.
|
|
14
|
+
*
|
|
15
|
+
* What is left is the work a client genuinely owns: turning the server's row
|
|
16
|
+
* objects into something readable, and the two limits that are cheaper to check
|
|
17
|
+
* here than to discover from a remote error.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The documented request-body ceiling for `POST /byog/query`.
|
|
21
|
+
*
|
|
22
|
+
* Enforced before the request goes out. The server answers an oversize body
|
|
23
|
+
* with 413 — but only after the whole thing has been uploaded, which on a bulk
|
|
24
|
+
* load is the slowest possible way to learn the batch was too big.
|
|
25
|
+
*/
|
|
26
|
+
export declare const MAX_BODY_BYTES: number;
|
|
27
|
+
/** Collection names the server accepts. Rejecting locally names the rule. */
|
|
28
|
+
export declare const COLLECTION_PATTERN: RegExp;
|
|
29
|
+
type Row = Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* One returned value, rendered the way a graph user reads it.
|
|
32
|
+
*
|
|
33
|
+
* A node comes back as a flat object mixing its properties with `id` and
|
|
34
|
+
* `labels`; dumping that as raw JSON makes the caller do the separating. This
|
|
35
|
+
* renders `(:Person {name: "Alice"})` instead, which is both shorter and the
|
|
36
|
+
* notation the query was written in.
|
|
37
|
+
*/
|
|
38
|
+
export declare function renderValue(value: unknown): string;
|
|
39
|
+
/**
|
|
40
|
+
* Rows as a table, bounded.
|
|
41
|
+
*
|
|
42
|
+
* A traversal can return far more than the caller can use, and unlike the
|
|
43
|
+
* memory tools there is no server-side relevance ranking to lean on — the query
|
|
44
|
+
* asked for exactly this. So the ceiling is on the rendering, and what was
|
|
45
|
+
* dropped is stated rather than silently cut.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderRows(rows: Row[], opts?: {
|
|
48
|
+
maxRows?: number;
|
|
49
|
+
maxChars?: number;
|
|
50
|
+
}): string;
|
|
51
|
+
export {};
|
package/dist/cypher.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering and local limits for the BYOG (Bring Your Own Graph) tool.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately contains NO Cypher analysis. An earlier version lexed the query
|
|
5
|
+
* to classify reads vs writes and to pre-reject constructs the server refuses,
|
|
6
|
+
* which meant a second, worse implementation of the server's own rules living
|
|
7
|
+
* in a client: it could only ever agree with the server or be wrong, and being
|
|
8
|
+
* wrong meant refusing a query HydraDB would have run.
|
|
9
|
+
*
|
|
10
|
+
* The server is the authority on what Cypher is valid and permitted. It rejects
|
|
11
|
+
* unsupported constructs before executing anything — verified: a query mixing
|
|
12
|
+
* CREATE with a procedure call leaves the node count unchanged — and its
|
|
13
|
+
* messages are more specific than the ones this file used to produce.
|
|
14
|
+
*
|
|
15
|
+
* What is left is the work a client genuinely owns: turning the server's row
|
|
16
|
+
* objects into something readable, and the two limits that are cheaper to check
|
|
17
|
+
* here than to discover from a remote error.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The documented request-body ceiling for `POST /byog/query`.
|
|
21
|
+
*
|
|
22
|
+
* Enforced before the request goes out. The server answers an oversize body
|
|
23
|
+
* with 413 — but only after the whole thing has been uploaded, which on a bulk
|
|
24
|
+
* load is the slowest possible way to learn the batch was too big.
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_BODY_BYTES = 256 * 1024;
|
|
27
|
+
/** Collection names the server accepts. Rejecting locally names the rule. */
|
|
28
|
+
export const COLLECTION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
29
|
+
// --- Result rendering ---
|
|
30
|
+
/**
|
|
31
|
+
* The renderer-added keys on a returned node or relationship.
|
|
32
|
+
*
|
|
33
|
+
* These are added by HydraDB's renderer, not stored by the user, so they are
|
|
34
|
+
* separated from the real properties when rendering. A stored property with one
|
|
35
|
+
* of these names is shadowed in the response — which is worth knowing but is
|
|
36
|
+
* the server's behaviour, not something this file can fix.
|
|
37
|
+
*/
|
|
38
|
+
const NODE_KEYS = ["id", "labels"];
|
|
39
|
+
const REL_KEYS = ["id", "relation", "source_node_id", "target_node_id"];
|
|
40
|
+
function isRecord(value) {
|
|
41
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
function isNode(value) {
|
|
44
|
+
return isRecord(value) && "labels" in value && "id" in value;
|
|
45
|
+
}
|
|
46
|
+
function isRelationship(value) {
|
|
47
|
+
return isRecord(value) && "relation" in value && "source_node_id" in value;
|
|
48
|
+
}
|
|
49
|
+
function isPath(value) {
|
|
50
|
+
return (isRecord(value) &&
|
|
51
|
+
Array.isArray(value.nodes) &&
|
|
52
|
+
Array.isArray(value.edges));
|
|
53
|
+
}
|
|
54
|
+
function properties(value, reserved) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [key, val] of Object.entries(value)) {
|
|
57
|
+
if (!reserved.includes(key))
|
|
58
|
+
out[key] = val;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function inline(value) {
|
|
63
|
+
if (value === null)
|
|
64
|
+
return "null";
|
|
65
|
+
if (typeof value === "string")
|
|
66
|
+
return value;
|
|
67
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
68
|
+
return String(value);
|
|
69
|
+
try {
|
|
70
|
+
return JSON.stringify(value) ?? String(value);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return String(value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function propsToString(props) {
|
|
77
|
+
const entries = Object.entries(props);
|
|
78
|
+
if (entries.length === 0)
|
|
79
|
+
return "";
|
|
80
|
+
return ` {${entries.map(([k, v]) => `${k}: ${inline(v)}`).join(", ")}}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* One returned value, rendered the way a graph user reads it.
|
|
84
|
+
*
|
|
85
|
+
* A node comes back as a flat object mixing its properties with `id` and
|
|
86
|
+
* `labels`; dumping that as raw JSON makes the caller do the separating. This
|
|
87
|
+
* renders `(:Person {name: "Alice"})` instead, which is both shorter and the
|
|
88
|
+
* notation the query was written in.
|
|
89
|
+
*/
|
|
90
|
+
export function renderValue(value) {
|
|
91
|
+
if (isPath(value)) {
|
|
92
|
+
const nodes = value.nodes.map((n) => renderValue(n));
|
|
93
|
+
const edges = value.edges.map((e) => isRelationship(e) ? String(e.relation) : "?");
|
|
94
|
+
// Interleave nodes and edges in traversal order: (a)-[R]->(b)-[S]->(c).
|
|
95
|
+
const parts = [];
|
|
96
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
97
|
+
parts.push(nodes[i] ?? "");
|
|
98
|
+
if (i < edges.length)
|
|
99
|
+
parts.push(`-[:${edges[i]}]->`);
|
|
100
|
+
}
|
|
101
|
+
return parts.join("");
|
|
102
|
+
}
|
|
103
|
+
if (isNode(value)) {
|
|
104
|
+
const labels = Array.isArray(value.labels)
|
|
105
|
+
? value.labels.map((l) => `:${String(l)}`).join("")
|
|
106
|
+
: "";
|
|
107
|
+
return `(${labels}${propsToString(properties(value, NODE_KEYS))})`;
|
|
108
|
+
}
|
|
109
|
+
if (isRelationship(value)) {
|
|
110
|
+
return (`[${value.source_node_id}]-[:${value.relation}` +
|
|
111
|
+
`${propsToString(properties(value, REL_KEYS))}]->[${value.target_node_id}]`);
|
|
112
|
+
}
|
|
113
|
+
return inline(value);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Rows as a table, bounded.
|
|
117
|
+
*
|
|
118
|
+
* A traversal can return far more than the caller can use, and unlike the
|
|
119
|
+
* memory tools there is no server-side relevance ranking to lean on — the query
|
|
120
|
+
* asked for exactly this. So the ceiling is on the rendering, and what was
|
|
121
|
+
* dropped is stated rather than silently cut.
|
|
122
|
+
*/
|
|
123
|
+
export function renderRows(rows, opts = {}) {
|
|
124
|
+
const maxRows = opts.maxRows ?? 100;
|
|
125
|
+
const maxChars = opts.maxChars ?? 20000;
|
|
126
|
+
if (rows.length === 0)
|
|
127
|
+
return "(0 rows)";
|
|
128
|
+
const shown = rows.slice(0, maxRows);
|
|
129
|
+
const columns = [...new Set(shown.flatMap((row) => Object.keys(row)))];
|
|
130
|
+
const lines = [];
|
|
131
|
+
for (const [index, row] of shown.entries()) {
|
|
132
|
+
const cells = columns.map((col) => col in row ? `${col}: ${renderValue(row[col])}` : `${col}: —`);
|
|
133
|
+
lines.push(`${index + 1}. ${cells.join(" | ")}`);
|
|
134
|
+
}
|
|
135
|
+
let body = lines.join("\n");
|
|
136
|
+
if (body.length > maxChars) {
|
|
137
|
+
body = `${body.slice(0, maxChars)}\n[truncated: ${body.length} chars of rendered rows]`;
|
|
138
|
+
}
|
|
139
|
+
const omitted = rows.length - shown.length;
|
|
140
|
+
const footer = omitted > 0
|
|
141
|
+
? `\n\n[${omitted} more row(s) not shown — add SKIP/LIMIT to page through them]`
|
|
142
|
+
: "";
|
|
143
|
+
return `${body}${footer}`;
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=cypher.js.map
|