@emseepea/create-mongodb-backed-server 0.0.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/README.md +109 -0
- package/initializer-dist/LICENSE +21 -0
- package/initializer-dist/create.mjs +44 -0
- package/initializer-dist/template/README.md +80 -0
- package/initializer-dist/template/compose.yaml +16 -0
- package/initializer-dist/template/eval/meaning.test.mjs +75 -0
- package/initializer-dist/template/package.json +35 -0
- package/initializer-dist/template/src/app.ts +49 -0
- package/initializer-dist/template/src/capabilities/tool.add-pea-variety.ts +25 -0
- package/initializer-dist/template/src/capabilities/tool.list-pea-observations.ts +46 -0
- package/initializer-dist/template/src/capabilities/tool.list-pea-varieties.ts +36 -0
- package/initializer-dist/template/src/capabilities/tool.record-pea-observation.ts +25 -0
- package/initializer-dist/template/src/database.ts +29 -0
- package/initializer-dist/template/src/pea-document.ts +25 -0
- package/initializer-dist/template/src/pea-observation-document.ts +26 -0
- package/initializer-dist/template/src/pea-observation.ts +10 -0
- package/initializer-dist/template/src/pea-variety.ts +10 -0
- package/initializer-dist/template/src/server.ts +21 -0
- package/initializer-dist/template/src/setup-database.ts +70 -0
- package/initializer-dist/template/test/server.test.mjs +341 -0
- package/initializer-dist/template/test/with-mongodb.mjs +74 -0
- package/initializer-dist/template/tsconfig.json +13 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# `@emseepea/create-mongodb-backed-server`
|
|
2
|
+
|
|
3
|
+
This directory is both the maintained example and the template source for its
|
|
4
|
+
public npm initializer.
|
|
5
|
+
|
|
6
|
+
## Use This Template
|
|
7
|
+
|
|
8
|
+
Use this template when MongoDB stores the data and your collections use either
|
|
9
|
+
database-enforced schemas or schemaless storage. It shows both approaches in
|
|
10
|
+
one project, using the official driver directly without an object-document
|
|
11
|
+
mapper.
|
|
12
|
+
|
|
13
|
+
Choose the [database-schema server](../database-schema-server/README.md) when
|
|
14
|
+
PostgreSQL is authoritative, or the [API-backed server](../api-backed-server/README.md)
|
|
15
|
+
for an HTTP service. [Compare all templates](https://emseepea.github.io/emseepea/examples/).
|
|
16
|
+
|
|
17
|
+
## Create a Project
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm init @emseepea/mongodb-backed-server -- my-server
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The command creates a standalone app in a new `my-server` directory and sets
|
|
24
|
+
`private: true` in its `package.json` so the app cannot be published by
|
|
25
|
+
accident. It includes Docker Compose, lint checks, ordinary tests, and semantic
|
|
26
|
+
tests.
|
|
27
|
+
|
|
28
|
+
<!-- generated-project-readme -->
|
|
29
|
+
|
|
30
|
+
## MongoDB-Backed Server
|
|
31
|
+
|
|
32
|
+
`pea_varieties` is schema-enforced. `src/pea-document.ts` contains its only
|
|
33
|
+
stored-document schema. MongoDB applies that exact object as its collection
|
|
34
|
+
validator, Ajv checks data in the application, and `FromSchema` infers its
|
|
35
|
+
TypeScript type.
|
|
36
|
+
|
|
37
|
+
`pea_observations` is intentionally schemaless in MongoDB.
|
|
38
|
+
`src/pea-observation-document.ts` contains its only application schema. Ajv
|
|
39
|
+
validates every observation before a write and after a read, while `FromSchema`
|
|
40
|
+
infers its TypeScript type. This protects the MCP boundary without pretending
|
|
41
|
+
that MongoDB will reject writes made outside the application.
|
|
42
|
+
|
|
43
|
+
Choose the approach collection by collection. Do not copy either JSON Schema
|
|
44
|
+
into a TypeScript interface or a second field validator.
|
|
45
|
+
|
|
46
|
+
The public MCP schemas remain small and described for an AI. A compatible new
|
|
47
|
+
string value, such as another pea type, passes through without a translation
|
|
48
|
+
map or application release. MongoDB `_id` values stay internal.
|
|
49
|
+
|
|
50
|
+
## Run Locally
|
|
51
|
+
|
|
52
|
+
You need Node.js 22 or 24 and Docker Compose.
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npm install
|
|
56
|
+
npm run dev
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Stop the server with Control-C. Remove the local database and volume when you
|
|
60
|
+
no longer need them:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
docker compose down --volumes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For a managed MongoDB deployment, set `MONGODB_URL`, build, apply the collection
|
|
67
|
+
schema once, then start the server:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
npm run build
|
|
71
|
+
npm run db:setup
|
|
72
|
+
npm start
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use the database name in the connection URL, such as
|
|
76
|
+
`mongodb://127.0.0.1:27017/emseepea`. When it is omitted, the example uses
|
|
77
|
+
`emseepea`.
|
|
78
|
+
|
|
79
|
+
Protect `MONGODB_URL` as a secret. Do not put it in source control or send it to
|
|
80
|
+
an MCP client.
|
|
81
|
+
|
|
82
|
+
## Tools
|
|
83
|
+
|
|
84
|
+
- `add-pea-variety` validates and inserts one document.
|
|
85
|
+
- `list-pea-varieties` reads a fixed projection of at most 20 varieties.
|
|
86
|
+
- `record-pea-observation` validates and records one dated observation.
|
|
87
|
+
- `list-pea-observations` reads a fixed projection of at most 20 observations.
|
|
88
|
+
|
|
89
|
+
The tools never accept database operators, collection names, sort documents,
|
|
90
|
+
or destinations. The pool and database operations are bounded, and provider
|
|
91
|
+
failures return a generic error.
|
|
92
|
+
|
|
93
|
+
## Check This Project
|
|
94
|
+
|
|
95
|
+
Run lint, build, and ordinary database integration tests without spending model
|
|
96
|
+
tokens:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npm run lint
|
|
100
|
+
npm test
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Run the more expensive AI test separately:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npm run test:llm
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
If Claude is not already signed in, run `claude auth login` first.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Windy Road Technology Pty. Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const [destination, ...extra] = process.argv.slice(2);
|
|
7
|
+
if (extra.length > 0 || !destination || !/^[a-z0-9][a-z0-9._-]*$/.test(destination)) {
|
|
8
|
+
throw new Error("Provide one simple lowercase destination name, such as my-server");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const target = resolve(destination);
|
|
12
|
+
if (basename(target) !== destination) throw new Error("The destination must not contain a path");
|
|
13
|
+
const staging = await mkdtemp(join(dirname(target), ".emseepea-create-"));
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await copyContents(new URL("./template/", import.meta.url), staging);
|
|
17
|
+
const manifestPath = resolve(staging, "package.json");
|
|
18
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
19
|
+
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: destination }, null, 2)}\n`);
|
|
20
|
+
await mkdir(target);
|
|
21
|
+
try {
|
|
22
|
+
await copyContents(staging, target);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
await rm(target, { recursive: true, force: true });
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (["EEXIST", "ENOTEMPTY"].includes(error.code)) {
|
|
29
|
+
throw new Error(`The destination already exists: ${destination}`);
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
} finally {
|
|
33
|
+
await rm(staging, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function copyContents(source, destination) {
|
|
37
|
+
for (const entry of await readdir(source)) {
|
|
38
|
+
const from = source instanceof URL ? new URL(entry, source) : join(source, entry);
|
|
39
|
+
await cp(from, join(destination, entry), { recursive: true, errorOnExist: true, force: false });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(`Created ${destination}.`);
|
|
44
|
+
console.log(`Next: cd ${destination}; npm install; npm test; npm start`);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# MongoDB-Backed Server
|
|
2
|
+
|
|
3
|
+
`pea_varieties` is schema-enforced. `src/pea-document.ts` contains its only
|
|
4
|
+
stored-document schema. MongoDB applies that exact object as its collection
|
|
5
|
+
validator, Ajv checks data in the application, and `FromSchema` infers its
|
|
6
|
+
TypeScript type.
|
|
7
|
+
|
|
8
|
+
`pea_observations` is intentionally schemaless in MongoDB.
|
|
9
|
+
`src/pea-observation-document.ts` contains its only application schema. Ajv
|
|
10
|
+
validates every observation before a write and after a read, while `FromSchema`
|
|
11
|
+
infers its TypeScript type. This protects the MCP boundary without pretending
|
|
12
|
+
that MongoDB will reject writes made outside the application.
|
|
13
|
+
|
|
14
|
+
Choose the approach collection by collection. Do not copy either JSON Schema
|
|
15
|
+
into a TypeScript interface or a second field validator.
|
|
16
|
+
|
|
17
|
+
The public MCP schemas remain small and described for an AI. A compatible new
|
|
18
|
+
string value, such as another pea type, passes through without a translation
|
|
19
|
+
map or application release. MongoDB `_id` values stay internal.
|
|
20
|
+
|
|
21
|
+
## Run Locally
|
|
22
|
+
|
|
23
|
+
You need Node.js 22 or 24 and Docker Compose.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install
|
|
27
|
+
npm run dev
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Stop the server with Control-C. Remove the local database and volume when you
|
|
31
|
+
no longer need them:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
docker compose down --volumes
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For a managed MongoDB deployment, set `MONGODB_URL`, build, apply the collection
|
|
38
|
+
schema once, then start the server:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm run build
|
|
42
|
+
npm run db:setup
|
|
43
|
+
npm start
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Use the database name in the connection URL, such as
|
|
47
|
+
`mongodb://127.0.0.1:27017/emseepea`. When it is omitted, the example uses
|
|
48
|
+
`emseepea`.
|
|
49
|
+
|
|
50
|
+
Protect `MONGODB_URL` as a secret. Do not put it in source control or send it to
|
|
51
|
+
an MCP client.
|
|
52
|
+
|
|
53
|
+
## Tools
|
|
54
|
+
|
|
55
|
+
- `add-pea-variety` validates and inserts one document.
|
|
56
|
+
- `list-pea-varieties` reads a fixed projection of at most 20 varieties.
|
|
57
|
+
- `record-pea-observation` validates and records one dated observation.
|
|
58
|
+
- `list-pea-observations` reads a fixed projection of at most 20 observations.
|
|
59
|
+
|
|
60
|
+
The tools never accept database operators, collection names, sort documents,
|
|
61
|
+
or destinations. The pool and database operations are bounded, and provider
|
|
62
|
+
failures return a generic error.
|
|
63
|
+
|
|
64
|
+
## Check This Project
|
|
65
|
+
|
|
66
|
+
Run lint, build, and ordinary database integration tests without spending model
|
|
67
|
+
tokens:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
npm run lint
|
|
71
|
+
npm test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Run the more expensive AI test separately:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npm run test:llm
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
If Claude is not already signed in, run `claude auth login` first.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
services:
|
|
2
|
+
database:
|
|
3
|
+
image: mongo:8.0.15-noble
|
|
4
|
+
command: ["mongod", "--quiet"]
|
|
5
|
+
healthcheck:
|
|
6
|
+
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
7
|
+
interval: 1s
|
|
8
|
+
timeout: 3s
|
|
9
|
+
retries: 30
|
|
10
|
+
ports:
|
|
11
|
+
- "127.0.0.1:${MONGODB_PORT:-27017}:27017"
|
|
12
|
+
volumes:
|
|
13
|
+
- mongodb-data:/data/db
|
|
14
|
+
|
|
15
|
+
volumes:
|
|
16
|
+
mongodb-data:
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import {
|
|
3
|
+
assertResponseContains,
|
|
4
|
+
assertResponseMeaning,
|
|
5
|
+
assertToolCalls,
|
|
6
|
+
createConversation,
|
|
7
|
+
} from "@emseepea/testing/semantic";
|
|
8
|
+
|
|
9
|
+
const trialUris = [1, 2, 3].map((trial) => {
|
|
10
|
+
const value = process.env[`MONGODB_URL_TRIAL_${trial}`];
|
|
11
|
+
if (!value) throw new Error(`MONGODB_URL_TRIAL_${trial} is required`);
|
|
12
|
+
return value;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("finds and adds MongoDB-backed pea varieties through natural requests", async (t) => {
|
|
16
|
+
const chat = await createConversation(t, {
|
|
17
|
+
server: new URL("../dist/server.js", import.meta.url),
|
|
18
|
+
environment: (trial) => ({ MONGODB_URL: trialUris[trial - 1] }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// The read and write turns cover both public decisions. Storage validation
|
|
22
|
+
// remains in deterministic tests because asking a model cannot prove it.
|
|
23
|
+
const fastest = await chat.send("Among the saved snap pea varieties, which matures fastest?");
|
|
24
|
+
assertToolCalls(fastest, [{
|
|
25
|
+
name: "list-pea-varieties",
|
|
26
|
+
arguments: { pea_type: "snap" },
|
|
27
|
+
}]);
|
|
28
|
+
await assertResponseMeaning(fastest, {
|
|
29
|
+
expected: "Sugar Ann is the fastest listed snap pea variety and matures in 56 days.",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const added = await chat.send(
|
|
33
|
+
"Add Golden Sweet as a climbing mangetout pea that matures in 70 days. " +
|
|
34
|
+
"Use exactly mangetout as its pea type. Its notes are: Purple flowers and flat edible pods.",
|
|
35
|
+
);
|
|
36
|
+
assertToolCalls(added, [{
|
|
37
|
+
name: "add-pea-variety",
|
|
38
|
+
arguments: {
|
|
39
|
+
name: "Golden Sweet",
|
|
40
|
+
pea_type: "mangetout",
|
|
41
|
+
growth_habit: "climbing",
|
|
42
|
+
days_to_maturity: 70,
|
|
43
|
+
notes: "Purple flowers and flat edible pods.",
|
|
44
|
+
},
|
|
45
|
+
}]);
|
|
46
|
+
assertResponseContains(added, ["Golden Sweet", "mangetout", "70"]);
|
|
47
|
+
|
|
48
|
+
// Two further turns cover the separate observation task. Database enforcement
|
|
49
|
+
// stays in ordinary tests because a model conversation cannot prove it.
|
|
50
|
+
const recorded = await chat.send(
|
|
51
|
+
"Record that Golden Sweet was flowering in the west trellis on 2026-09-08. " +
|
|
52
|
+
"The notes are: First flower opened.",
|
|
53
|
+
);
|
|
54
|
+
assertToolCalls(recorded, [{
|
|
55
|
+
name: "record-pea-observation",
|
|
56
|
+
arguments: {
|
|
57
|
+
variety_name: "Golden Sweet",
|
|
58
|
+
observed_on: "2026-09-08",
|
|
59
|
+
location: "west trellis",
|
|
60
|
+
growth_stage: "flowering",
|
|
61
|
+
notes: "First flower opened.",
|
|
62
|
+
},
|
|
63
|
+
}]);
|
|
64
|
+
|
|
65
|
+
const observations = await chat.send("What have I observed about Golden Sweet?");
|
|
66
|
+
assertToolCalls(observations, [{
|
|
67
|
+
name: "list-pea-observations",
|
|
68
|
+
arguments: { variety_name: "Golden Sweet" },
|
|
69
|
+
}]);
|
|
70
|
+
await assertResponseMeaning(observations, {
|
|
71
|
+
expected:
|
|
72
|
+
"Golden Sweet was flowering in the west trellis on 8 September 2026, " +
|
|
73
|
+
"and the first flower had opened.",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "emseepea-starter",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Create an Em See Pea server with schema-enforced and schemaless MongoDB collections.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json",
|
|
9
|
+
"db:setup": "node dist/setup-database.js",
|
|
10
|
+
"start": "node dist/server.js",
|
|
11
|
+
"dev": "docker compose up --detach --wait database && npm run build && npm run db:setup && npm start",
|
|
12
|
+
"test": "npm run build && npm run test:built",
|
|
13
|
+
"test:built": "node test/with-mongodb.mjs node --test test/*.test.mjs",
|
|
14
|
+
"test:llm": "npm run build && npm run test:llm:built",
|
|
15
|
+
"test:llm:built": "node test/with-mongodb.mjs emseepea-test eval",
|
|
16
|
+
"lint": "oxlint src test eval"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@emseepea/testing": "0.5.2",
|
|
20
|
+
"@types/node": "24.13.3",
|
|
21
|
+
"typescript": "6.0.3",
|
|
22
|
+
"oxlint": "1.80.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.13.0"
|
|
26
|
+
},
|
|
27
|
+
"private": true,
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@emseepea/server": "0.3.3",
|
|
30
|
+
"ajv": "8.20.0",
|
|
31
|
+
"json-schema-to-ts": "3.1.1",
|
|
32
|
+
"mongodb": "7.6.0",
|
|
33
|
+
"zod": "4.4.3"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createEmseepea, discoverCapabilities } from "@emseepea/server";
|
|
2
|
+
import type { Collection, Db } from "mongodb";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { createDatabase } from "./database.js";
|
|
5
|
+
import type { PeaObservationDocument } from "./pea-observation-document.js";
|
|
6
|
+
import type { PeaDocument } from "./pea-document.js";
|
|
7
|
+
|
|
8
|
+
export interface MongoExampleOptions { readonly uri: string }
|
|
9
|
+
|
|
10
|
+
export async function createMongoExample({ uri }: MongoExampleOptions) {
|
|
11
|
+
const parsedUri = z.string().url().refine(
|
|
12
|
+
(value) => ["mongodb:", "mongodb+srv:"].includes(new URL(value).protocol),
|
|
13
|
+
"uri must use MongoDB",
|
|
14
|
+
).parse(uri);
|
|
15
|
+
const connected = createDatabase(parsedUri);
|
|
16
|
+
let database: Db | undefined = connected.database;
|
|
17
|
+
let varieties: Collection<PeaDocument> | undefined = connected.varieties;
|
|
18
|
+
let observations: Collection<PeaObservationDocument> | undefined = connected.observations;
|
|
19
|
+
const app = createEmseepea({
|
|
20
|
+
name: "emseepea-mongodb-backed-server",
|
|
21
|
+
version: "0.0.0",
|
|
22
|
+
instructions: "Read and add pea varieties, and record and review pea observations.",
|
|
23
|
+
readiness: async ({ signal }) => {
|
|
24
|
+
if (!database) return false;
|
|
25
|
+
try {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
await database.command({ ping: 1 }, { timeoutMS: 1_500 });
|
|
28
|
+
signal.throwIfAborted();
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
readinessTimeoutMs: 2_500,
|
|
35
|
+
...await discoverCapabilities(new URL("./capabilities/", import.meta.url), {
|
|
36
|
+
database: () => database,
|
|
37
|
+
observations: () => observations,
|
|
38
|
+
varieties: () => varieties,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
const closeProvider = async () => {
|
|
42
|
+
observations = undefined;
|
|
43
|
+
varieties = undefined;
|
|
44
|
+
database = undefined;
|
|
45
|
+
await connected.client.close();
|
|
46
|
+
};
|
|
47
|
+
app.addHook("onClose", closeProvider);
|
|
48
|
+
return { app, closeProvider };
|
|
49
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
|
|
3
|
+
import { parsePeaDocument } from "../pea-document.js";
|
|
4
|
+
import { varietySchema } from "../pea-variety.js";
|
|
5
|
+
import type { MongoContext } from "../database.js";
|
|
6
|
+
|
|
7
|
+
const inputSchema = varietySchema;
|
|
8
|
+
const outputSchema = varietySchema;
|
|
9
|
+
|
|
10
|
+
export default ((context) => defineTool({
|
|
11
|
+
name: "add-pea-variety",
|
|
12
|
+
access: "public",
|
|
13
|
+
description: "Add one pea variety to the catalogue.",
|
|
14
|
+
inputSchema,
|
|
15
|
+
outputSchema,
|
|
16
|
+
async handler(variety, { signal }) {
|
|
17
|
+
const collection = context.varieties();
|
|
18
|
+
if (!collection) throw new Error("Variety provider unavailable");
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
const document = parsePeaDocument({ _id: randomUUID(), ...variety });
|
|
21
|
+
await collection.insertOne(document, { maxTimeMS: 1_500 });
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
return { data: variety };
|
|
24
|
+
},
|
|
25
|
+
})) satisfies CapabilityModuleFactory<MongoContext>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { MongoContext } from "../database.js";
|
|
4
|
+
import { parsePeaObservationDocument } from "../pea-observation-document.js";
|
|
5
|
+
import { observationSchema } from "../pea-observation.js";
|
|
6
|
+
|
|
7
|
+
const inputSchema = z.object({
|
|
8
|
+
variety_name: z.string().trim().min(1).max(100).optional()
|
|
9
|
+
.describe("Optional variety name to match exactly. Omit it to list every variety."),
|
|
10
|
+
});
|
|
11
|
+
const outputSchema = z.object({
|
|
12
|
+
observations: z.array(observationSchema).max(20)
|
|
13
|
+
.describe("Up to 20 recent pea observations, newest first."),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export default ((context) => defineTool({
|
|
17
|
+
name: "list-pea-observations",
|
|
18
|
+
access: "public",
|
|
19
|
+
description: "List up to 20 recent observations of pea plants.",
|
|
20
|
+
inputSchema,
|
|
21
|
+
outputSchema,
|
|
22
|
+
async handler({ variety_name }, { signal }) {
|
|
23
|
+
const collection = context.observations();
|
|
24
|
+
if (!collection) throw new Error("Observation provider unavailable");
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
const rows = await collection.find(
|
|
27
|
+
variety_name ? { variety_name } : {},
|
|
28
|
+
{
|
|
29
|
+
projection: {
|
|
30
|
+
_id: 1,
|
|
31
|
+
variety_name: 1,
|
|
32
|
+
observed_on: 1,
|
|
33
|
+
location: 1,
|
|
34
|
+
growth_stage: 1,
|
|
35
|
+
notes: 1,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
).sort({ observed_on: -1, variety_name: 1 }).limit(20).maxTimeMS(1_500).toArray();
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
const observations = rows.map((row) => {
|
|
41
|
+
const { _id: _internalId, ...observation } = parsePeaObservationDocument(row);
|
|
42
|
+
return observation;
|
|
43
|
+
});
|
|
44
|
+
return { data: { observations } };
|
|
45
|
+
},
|
|
46
|
+
})) satisfies CapabilityModuleFactory<MongoContext>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { parsePeaDocument } from "../pea-document.js";
|
|
4
|
+
import { varietySchema } from "../pea-variety.js";
|
|
5
|
+
import type { MongoContext } from "../database.js";
|
|
6
|
+
|
|
7
|
+
const inputSchema = z.object({
|
|
8
|
+
pea_type: z.string().trim().min(1).max(40).optional()
|
|
9
|
+
.describe("Optional pea type to match exactly. Omit it to list every type."),
|
|
10
|
+
});
|
|
11
|
+
const outputSchema = z.object({
|
|
12
|
+
varieties: z.array(varietySchema).max(20).describe("Up to 20 matching pea varieties ordered by name."),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default ((context) => defineTool({
|
|
16
|
+
name: "list-pea-varieties",
|
|
17
|
+
access: "public",
|
|
18
|
+
description: "List up to 20 pea varieties, optionally filtered by pea type.",
|
|
19
|
+
inputSchema,
|
|
20
|
+
outputSchema,
|
|
21
|
+
async handler({ pea_type }, { signal }) {
|
|
22
|
+
const collection = context.varieties();
|
|
23
|
+
if (!collection) throw new Error("Variety provider unavailable");
|
|
24
|
+
signal.throwIfAborted();
|
|
25
|
+
const rows = await collection.find(
|
|
26
|
+
pea_type ? { pea_type } : {},
|
|
27
|
+
{ projection: { _id: 1, name: 1, pea_type: 1, growth_habit: 1, days_to_maturity: 1, notes: 1 } },
|
|
28
|
+
).sort({ name: 1 }).limit(20).maxTimeMS(1_500).toArray();
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
const varieties = rows.map((row) => {
|
|
31
|
+
const { _id: _internalId, ...variety } = parsePeaDocument(row);
|
|
32
|
+
return variety;
|
|
33
|
+
});
|
|
34
|
+
return { data: { varieties } };
|
|
35
|
+
},
|
|
36
|
+
})) satisfies CapabilityModuleFactory<MongoContext>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
|
|
3
|
+
import type { MongoContext } from "../database.js";
|
|
4
|
+
import { parsePeaObservationDocument } from "../pea-observation-document.js";
|
|
5
|
+
import { observationSchema } from "../pea-observation.js";
|
|
6
|
+
|
|
7
|
+
const inputSchema = observationSchema;
|
|
8
|
+
const outputSchema = observationSchema;
|
|
9
|
+
|
|
10
|
+
export default ((context) => defineTool({
|
|
11
|
+
name: "record-pea-observation",
|
|
12
|
+
access: "public",
|
|
13
|
+
description: "Record one dated observation of a pea plant.",
|
|
14
|
+
inputSchema,
|
|
15
|
+
outputSchema,
|
|
16
|
+
async handler(observation, { signal }) {
|
|
17
|
+
const collection = context.observations();
|
|
18
|
+
if (!collection) throw new Error("Observation provider unavailable");
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
const document = parsePeaObservationDocument({ _id: randomUUID(), ...observation });
|
|
21
|
+
await collection.insertOne(document, { maxTimeMS: 1_500 });
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
return { data: observation };
|
|
24
|
+
},
|
|
25
|
+
})) satisfies CapabilityModuleFactory<MongoContext>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MongoClient, type Collection, type Db } from "mongodb";
|
|
2
|
+
import type { PeaObservationDocument } from "./pea-observation-document.js";
|
|
3
|
+
import type { PeaDocument } from "./pea-document.js";
|
|
4
|
+
|
|
5
|
+
export const varietyCollectionName = "pea_varieties";
|
|
6
|
+
export const observationCollectionName = "pea_observations";
|
|
7
|
+
export const databaseName = "emseepea";
|
|
8
|
+
|
|
9
|
+
export function createDatabase(uri: string) {
|
|
10
|
+
const client = new MongoClient(uri, {
|
|
11
|
+
connectTimeoutMS: 2_000,
|
|
12
|
+
maxPoolSize: 4,
|
|
13
|
+
serverSelectionTimeoutMS: 2_000,
|
|
14
|
+
});
|
|
15
|
+
const selectedDatabaseName = decodeURIComponent(new URL(uri).pathname.slice(1)) || databaseName;
|
|
16
|
+
const database = client.db(selectedDatabaseName);
|
|
17
|
+
return {
|
|
18
|
+
client,
|
|
19
|
+
database,
|
|
20
|
+
varieties: database.collection<PeaDocument>(varietyCollectionName),
|
|
21
|
+
observations: database.collection<PeaObservationDocument>(observationCollectionName),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MongoContext {
|
|
26
|
+
readonly varieties: () => Collection<PeaDocument> | undefined;
|
|
27
|
+
readonly observations: () => Collection<PeaObservationDocument> | undefined;
|
|
28
|
+
readonly database: () => Db | undefined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
import type { FromSchema } from "json-schema-to-ts";
|
|
3
|
+
|
|
4
|
+
export const peaDocumentSchema = {
|
|
5
|
+
type: "object",
|
|
6
|
+
additionalProperties: false,
|
|
7
|
+
required: ["_id", "name", "pea_type", "growth_habit", "days_to_maturity", "notes"],
|
|
8
|
+
properties: {
|
|
9
|
+
_id: { type: "string", minLength: 1, maxLength: 100 },
|
|
10
|
+
name: { type: "string", minLength: 1, maxLength: 100 },
|
|
11
|
+
pea_type: { type: "string", minLength: 1, maxLength: 40 },
|
|
12
|
+
growth_habit: { type: "string", minLength: 1, maxLength: 40 },
|
|
13
|
+
days_to_maturity: { type: "number", multipleOf: 1, minimum: 1, maximum: 365 },
|
|
14
|
+
notes: { type: "string", maxLength: 500 },
|
|
15
|
+
},
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export type PeaDocument = FromSchema<typeof peaDocumentSchema>;
|
|
19
|
+
|
|
20
|
+
const validate = new Ajv({ strict: true }).compile<PeaDocument>(peaDocumentSchema);
|
|
21
|
+
|
|
22
|
+
export function parsePeaDocument(value: unknown): PeaDocument {
|
|
23
|
+
if (!validate(value)) throw new Error("Invalid pea document");
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Ajv } from "ajv";
|
|
2
|
+
import type { FromSchema } from "json-schema-to-ts";
|
|
3
|
+
|
|
4
|
+
export const peaObservationDocumentSchema = {
|
|
5
|
+
type: "object",
|
|
6
|
+
additionalProperties: false,
|
|
7
|
+
required: ["_id", "variety_name", "observed_on", "location", "growth_stage", "notes"],
|
|
8
|
+
properties: {
|
|
9
|
+
_id: { type: "string", minLength: 1, maxLength: 100 },
|
|
10
|
+
variety_name: { type: "string", minLength: 1, maxLength: 100 },
|
|
11
|
+
observed_on: { type: "string", pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" },
|
|
12
|
+
location: { type: "string", minLength: 1, maxLength: 100 },
|
|
13
|
+
growth_stage: { type: "string", minLength: 1, maxLength: 40 },
|
|
14
|
+
notes: { type: "string", maxLength: 500 },
|
|
15
|
+
},
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export type PeaObservationDocument = FromSchema<typeof peaObservationDocumentSchema>;
|
|
19
|
+
|
|
20
|
+
const validate = new Ajv({ strict: true })
|
|
21
|
+
.compile<PeaObservationDocument>(peaObservationDocumentSchema);
|
|
22
|
+
|
|
23
|
+
export function parsePeaObservationDocument(value: unknown): PeaObservationDocument {
|
|
24
|
+
if (!validate(value)) throw new Error("Invalid pea observation document");
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const observationSchema = z.object({
|
|
4
|
+
variety_name: z.string().min(1).max(100).describe("Name of the observed pea variety."),
|
|
5
|
+
observed_on: z.iso.date().describe("Calendar date of the observation in YYYY-MM-DD format."),
|
|
6
|
+
location: z.string().min(1).max(100).describe("Short human-readable name of the growing location."),
|
|
7
|
+
growth_stage: z.string().min(1).max(40)
|
|
8
|
+
.describe("Observed growth stage, such as germinating, flowering, or podding."),
|
|
9
|
+
notes: z.string().max(500).describe("Short notes about what was observed."),
|
|
10
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const varietySchema = z.object({
|
|
4
|
+
name: z.string().min(1).max(100).describe("Name of the pea variety."),
|
|
5
|
+
pea_type: z.string().min(1).max(40).describe("Type of pea, such as shelling, snap, or snow."),
|
|
6
|
+
growth_habit: z.string().min(1).max(40).describe("How the plant grows, such as bush or climbing."),
|
|
7
|
+
days_to_maturity: z.number().int().min(1).max(365)
|
|
8
|
+
.describe("Typical number of days from sowing to harvest."),
|
|
9
|
+
notes: z.string().max(500).describe("Short growing or eating notes."),
|
|
10
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { serveEmseepea } from "@emseepea/server";
|
|
2
|
+
import { createMongoExample } from "./app.js";
|
|
3
|
+
|
|
4
|
+
const uri = process.env.MONGODB_URL ?? "mongodb://127.0.0.1:27017";
|
|
5
|
+
const { app } = await createMongoExample({ uri });
|
|
6
|
+
const running = await serveEmseepea(app, {
|
|
7
|
+
port: Number.parseInt(process.env.PORT ?? "3000", 10),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
console.log(`Em See Pea MongoDB-backed example listening at ${running.url}`);
|
|
11
|
+
|
|
12
|
+
let shuttingDown = false;
|
|
13
|
+
async function shutdown(): Promise<void> {
|
|
14
|
+
if (shuttingDown) return;
|
|
15
|
+
shuttingDown = true;
|
|
16
|
+
await running.close();
|
|
17
|
+
process.exitCode = 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
process.once("SIGINT", () => void shutdown());
|
|
21
|
+
process.once("SIGTERM", () => void shutdown());
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { MongoServerError } from "mongodb";
|
|
2
|
+
import {
|
|
3
|
+
observationCollectionName,
|
|
4
|
+
varietyCollectionName,
|
|
5
|
+
createDatabase,
|
|
6
|
+
} from "./database.js";
|
|
7
|
+
import { parsePeaObservationDocument } from "./pea-observation-document.js";
|
|
8
|
+
import { peaDocumentSchema, type PeaDocument } from "./pea-document.js";
|
|
9
|
+
|
|
10
|
+
const uri = process.env.MONGODB_URL ?? "mongodb://127.0.0.1:27017";
|
|
11
|
+
const { client, database, observations, varieties } = createDatabase(uri);
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await client.connect();
|
|
15
|
+
const validator = { $jsonSchema: peaDocumentSchema };
|
|
16
|
+
try {
|
|
17
|
+
await database.createCollection(varietyCollectionName, { validator });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (!(error instanceof MongoServerError) || error.codeName !== "NamespaceExists") throw error;
|
|
20
|
+
await database.command({ collMod: varietyCollectionName, validator });
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
await database.createCollection(observationCollectionName);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (!(error instanceof MongoServerError) || error.codeName !== "NamespaceExists") throw error;
|
|
26
|
+
const [collection] = await database.listCollections(
|
|
27
|
+
{ name: observationCollectionName },
|
|
28
|
+
{ nameOnly: false },
|
|
29
|
+
).toArray();
|
|
30
|
+
if (!collection) throw new Error(`${observationCollectionName} was not found`);
|
|
31
|
+
if (collection.options?.validator !== undefined) {
|
|
32
|
+
throw new Error(`${observationCollectionName} must not have a MongoDB validator`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const seeds: PeaDocument[] = [
|
|
36
|
+
{
|
|
37
|
+
_id: "green-arrow",
|
|
38
|
+
name: "Green Arrow",
|
|
39
|
+
pea_type: "shelling",
|
|
40
|
+
growth_habit: "climbing",
|
|
41
|
+
days_to_maturity: 68,
|
|
42
|
+
notes: "Long pods with sweet peas.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
_id: "sugar-ann",
|
|
46
|
+
name: "Sugar Ann",
|
|
47
|
+
pea_type: "snap",
|
|
48
|
+
growth_habit: "bush",
|
|
49
|
+
days_to_maturity: 56,
|
|
50
|
+
notes: "Compact plants with edible pods.",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
for (const seed of seeds) {
|
|
54
|
+
await varieties.replaceOne({ _id: seed._id }, seed, { upsert: true });
|
|
55
|
+
}
|
|
56
|
+
await observations.replaceOne(
|
|
57
|
+
{ _id: "sugar-ann-flowering" },
|
|
58
|
+
parsePeaObservationDocument({
|
|
59
|
+
_id: "sugar-ann-flowering",
|
|
60
|
+
variety_name: "Sugar Ann",
|
|
61
|
+
observed_on: "2026-08-30",
|
|
62
|
+
location: "North bed",
|
|
63
|
+
growth_stage: "flowering",
|
|
64
|
+
notes: "First flowers opened.",
|
|
65
|
+
}),
|
|
66
|
+
{ upsert: true },
|
|
67
|
+
);
|
|
68
|
+
} finally {
|
|
69
|
+
await client.close();
|
|
70
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import test, { after } from "node:test";
|
|
4
|
+
|
|
5
|
+
import { startMcpServer } from "@emseepea/testing";
|
|
6
|
+
import { MongoClient } from "mongodb";
|
|
7
|
+
import { createMongoExample } from "../dist/app.js";
|
|
8
|
+
import {
|
|
9
|
+
databaseName,
|
|
10
|
+
observationCollectionName,
|
|
11
|
+
varietyCollectionName,
|
|
12
|
+
} from "../dist/database.js";
|
|
13
|
+
import {
|
|
14
|
+
parsePeaObservationDocument,
|
|
15
|
+
peaObservationDocumentSchema,
|
|
16
|
+
} from "../dist/pea-observation-document.js";
|
|
17
|
+
import { parsePeaDocument, peaDocumentSchema } from "../dist/pea-document.js";
|
|
18
|
+
|
|
19
|
+
const uri = process.env.MONGODB_URL;
|
|
20
|
+
assert.ok(uri, "MONGODB_URL is required");
|
|
21
|
+
const provider = new MongoClient(uri, { maxPoolSize: 2, serverSelectionTimeoutMS: 2_000 });
|
|
22
|
+
await provider.connect();
|
|
23
|
+
const database = provider.db(databaseName);
|
|
24
|
+
const observations = database.collection(observationCollectionName);
|
|
25
|
+
const varieties = database.collection(varietyCollectionName);
|
|
26
|
+
after(() => provider.close());
|
|
27
|
+
const requestMeta = {
|
|
28
|
+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
29
|
+
"io.modelcontextprotocol/clientInfo": { name: "mongodb-test", version: "0.0.0" },
|
|
30
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
test("each collection has one application schema and only varieties enforce it in MongoDB", async () => {
|
|
34
|
+
const [varietyCollection] = await database
|
|
35
|
+
.listCollections({ name: varietyCollectionName }).toArray();
|
|
36
|
+
const [observationCollection] = await database
|
|
37
|
+
.listCollections({ name: observationCollectionName }).toArray();
|
|
38
|
+
assert.deepEqual(varietyCollection.options.validator, { $jsonSchema: peaDocumentSchema });
|
|
39
|
+
assert.equal(observationCollection.options.validator, undefined);
|
|
40
|
+
assert.throws(() => parsePeaDocument({ name: "missing required fields" }));
|
|
41
|
+
assert.throws(() => parsePeaObservationDocument({ variety_name: "missing required fields" }));
|
|
42
|
+
await assert.rejects(varieties.insertOne({
|
|
43
|
+
_id: "invalid-write",
|
|
44
|
+
name: "Invalid Write",
|
|
45
|
+
pea_type: "snap",
|
|
46
|
+
growth_habit: "bush",
|
|
47
|
+
days_to_maturity: "soon",
|
|
48
|
+
notes: "MongoDB must reject this.",
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const invalidObservation = { _id: "invalid-direct-write", variety_name: "Schemaless" };
|
|
52
|
+
await observations.insertOne(invalidObservation);
|
|
53
|
+
assert.deepEqual(await observations.findOne({ _id: invalidObservation._id }), invalidObservation);
|
|
54
|
+
await observations.deleteOne({ _id: invalidObservation._id });
|
|
55
|
+
assert.deepEqual(Object.keys(peaObservationDocumentSchema.properties), [
|
|
56
|
+
"_id", "variety_name", "observed_on", "location", "growth_stage", "notes",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("setup fails closed when the schemaless collection already has a validator", async () => {
|
|
61
|
+
await database.command({
|
|
62
|
+
collMod: observationCollectionName,
|
|
63
|
+
validator: { $jsonSchema: { bsonType: "object", required: ["unexpected"] } },
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const rejected = spawnSync(process.execPath, ["dist/setup-database.js"], {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
env: { ...process.env, MONGODB_URL: uri },
|
|
69
|
+
});
|
|
70
|
+
assert.notEqual(rejected.status, 0);
|
|
71
|
+
assert.match(rejected.stderr, /pea_observations must not have a MongoDB validator/);
|
|
72
|
+
} finally {
|
|
73
|
+
await observations.drop();
|
|
74
|
+
const restored = spawnSync(process.execPath, ["dist/setup-database.js"], {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
env: { ...process.env, MONGODB_URL: uri },
|
|
77
|
+
});
|
|
78
|
+
assert.equal(restored.status, 0, restored.stderr);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("reads, writes, and passes through a compatible new value", async (t) => {
|
|
83
|
+
const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
|
|
84
|
+
environment: { MONGODB_URL: uri },
|
|
85
|
+
});
|
|
86
|
+
const client = await running.connect();
|
|
87
|
+
const snap = await client.callTool({ name: "list-pea-varieties", arguments: { pea_type: "snap" } });
|
|
88
|
+
assert.deepEqual(snap.structuredContent, {
|
|
89
|
+
varieties: [{
|
|
90
|
+
name: "Sugar Ann",
|
|
91
|
+
pea_type: "snap",
|
|
92
|
+
growth_habit: "bush",
|
|
93
|
+
days_to_maturity: 56,
|
|
94
|
+
notes: "Compact plants with edible pods.",
|
|
95
|
+
}],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const newVariety = {
|
|
99
|
+
name: "Golden Sweet",
|
|
100
|
+
pea_type: "mangetout",
|
|
101
|
+
growth_habit: "climbing",
|
|
102
|
+
days_to_maturity: 70,
|
|
103
|
+
notes: "Purple flowers and flat edible pods.",
|
|
104
|
+
};
|
|
105
|
+
const added = await client.callTool({ name: "add-pea-variety", arguments: newVariety });
|
|
106
|
+
assert.equal(added.isError, false);
|
|
107
|
+
assert.deepEqual(added.structuredContent, newVariety);
|
|
108
|
+
assert.equal(Object.hasOwn(added.structuredContent, "_id"), false);
|
|
109
|
+
|
|
110
|
+
const passedThrough = await client.callTool({
|
|
111
|
+
name: "list-pea-varieties",
|
|
112
|
+
arguments: { pea_type: "mangetout" },
|
|
113
|
+
});
|
|
114
|
+
assert.deepEqual(passedThrough.structuredContent, { varieties: [newVariety] });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("records and reads observations while passing through a compatible new value", async (t) => {
|
|
118
|
+
const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
|
|
119
|
+
environment: { MONGODB_URL: uri },
|
|
120
|
+
});
|
|
121
|
+
const client = await running.connect();
|
|
122
|
+
const newObservation = {
|
|
123
|
+
variety_name: "Golden Sweet",
|
|
124
|
+
observed_on: "2026-09-08",
|
|
125
|
+
location: "West trellis",
|
|
126
|
+
growth_stage: "tendrilling",
|
|
127
|
+
notes: "New tendrils reached the support.",
|
|
128
|
+
};
|
|
129
|
+
const recorded = await client.callTool({
|
|
130
|
+
name: "record-pea-observation",
|
|
131
|
+
arguments: newObservation,
|
|
132
|
+
});
|
|
133
|
+
assert.equal(recorded.isError, false);
|
|
134
|
+
assert.deepEqual(recorded.structuredContent, newObservation);
|
|
135
|
+
assert.equal(Object.hasOwn(recorded.structuredContent, "_id"), false);
|
|
136
|
+
|
|
137
|
+
const listed = await client.callTool({
|
|
138
|
+
name: "list-pea-observations",
|
|
139
|
+
arguments: { variety_name: "Golden Sweet" },
|
|
140
|
+
});
|
|
141
|
+
assert.deepEqual(listed.structuredContent, { observations: [newObservation] });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("invalid stored documents never reach public output", async (t) => {
|
|
145
|
+
await varieties.insertOne({
|
|
146
|
+
_id: "invalid-read",
|
|
147
|
+
name: "Invalid Read",
|
|
148
|
+
pea_type: "test-invalid",
|
|
149
|
+
growth_habit: "bush",
|
|
150
|
+
days_to_maturity: "soon",
|
|
151
|
+
notes: "Inserted only to prove read validation.",
|
|
152
|
+
}, { bypassDocumentValidation: true });
|
|
153
|
+
t.after(() => varieties.deleteOne({ _id: "invalid-read" }));
|
|
154
|
+
const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
|
|
155
|
+
environment: { MONGODB_URL: uri },
|
|
156
|
+
});
|
|
157
|
+
assertGenericToolFailure(await rawCall(running.url, "list-pea-varieties", {
|
|
158
|
+
pea_type: "test-invalid",
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
await observations.insertOne({
|
|
162
|
+
_id: "invalid-observation-read",
|
|
163
|
+
variety_name: "Invalid Observation",
|
|
164
|
+
observed_on: "not-a-date",
|
|
165
|
+
location: "Test bed",
|
|
166
|
+
growth_stage: "test-invalid",
|
|
167
|
+
notes: "Inserted only to prove read validation.",
|
|
168
|
+
});
|
|
169
|
+
t.after(() => observations.deleteOne({ _id: "invalid-observation-read" }));
|
|
170
|
+
assertGenericToolFailure(await rawCall(running.url, "list-pea-observations", {
|
|
171
|
+
variety_name: "Invalid Observation",
|
|
172
|
+
}));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("public schemas are described, bounded, and hide database details", async (t) => {
|
|
176
|
+
const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
|
|
177
|
+
environment: { MONGODB_URL: uri },
|
|
178
|
+
});
|
|
179
|
+
const client = await running.connect();
|
|
180
|
+
const { tools } = await client.listTools();
|
|
181
|
+
assert.deepEqual(tools.map(({ name }) => name), [
|
|
182
|
+
"add-pea-variety",
|
|
183
|
+
"list-pea-observations",
|
|
184
|
+
"list-pea-varieties",
|
|
185
|
+
"record-pea-observation",
|
|
186
|
+
]);
|
|
187
|
+
for (const tool of tools) {
|
|
188
|
+
for (const schema of [tool.inputSchema, tool.outputSchema]) {
|
|
189
|
+
for (const property of ["_id", "collection", "operator", "sort", "destination"]) {
|
|
190
|
+
assert.equal(Object.hasOwn(schema.properties, property), false);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const tool of tools) {
|
|
195
|
+
for (const schema of [tool.inputSchema, tool.outputSchema]) {
|
|
196
|
+
for (const property of Object.values(schema.properties)) {
|
|
197
|
+
assert.ok(property.description || property.type === "array");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await database.command({ profile: 0 });
|
|
203
|
+
await database.collection("system.profile").drop().catch(() => {});
|
|
204
|
+
await database.command({ profile: 2, slowms: 0 });
|
|
205
|
+
t.after(() => database.command({ profile: 0 }));
|
|
206
|
+
|
|
207
|
+
const countBefore = await varieties.countDocuments();
|
|
208
|
+
const rejected = await rawCall(running.url, "add-pea-variety", {
|
|
209
|
+
name: "Operator Attempt",
|
|
210
|
+
pea_type: { $ne: "snap" },
|
|
211
|
+
growth_habit: "bush",
|
|
212
|
+
days_to_maturity: 60,
|
|
213
|
+
notes: "Must not reach MongoDB.",
|
|
214
|
+
});
|
|
215
|
+
assert.equal(rejected.response.status, 200);
|
|
216
|
+
assert.equal(rejected.body.result.isError, true);
|
|
217
|
+
assert.equal(await varieties.countDocuments(), countBefore);
|
|
218
|
+
|
|
219
|
+
const observationCountBefore = await observations.countDocuments();
|
|
220
|
+
const rejectedObservation = await rawCall(running.url, "record-pea-observation", {
|
|
221
|
+
variety_name: "Operator Attempt",
|
|
222
|
+
observed_on: "2026-09-08",
|
|
223
|
+
location: "Test bed",
|
|
224
|
+
growth_stage: { $ne: "flowering" },
|
|
225
|
+
notes: "Must not reach MongoDB.",
|
|
226
|
+
});
|
|
227
|
+
assert.equal(rejectedObservation.response.status, 200);
|
|
228
|
+
assert.equal(rejectedObservation.body.result.isError, true);
|
|
229
|
+
assert.equal(await observations.countDocuments(), observationCountBefore);
|
|
230
|
+
|
|
231
|
+
await database.command({ profile: 0 });
|
|
232
|
+
const writes = await database.collection("system.profile").find({
|
|
233
|
+
$or: ["insert", "update", "delete", "findAndModify"].flatMap((command) => [
|
|
234
|
+
{ [`command.${command}`]: varietyCollectionName },
|
|
235
|
+
{ [`command.${command}`]: observationCollectionName },
|
|
236
|
+
]),
|
|
237
|
+
}).toArray();
|
|
238
|
+
assert.deepEqual(writes, []);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("both collection reads stay bounded and carry database timeouts", async (t) => {
|
|
242
|
+
const prefix = `bounded-${process.pid}-`;
|
|
243
|
+
const varietyDocuments = Array.from({ length: 25 }, (_, index) => ({
|
|
244
|
+
_id: `${prefix}variety-${index}`,
|
|
245
|
+
name: `${prefix}${String(index).padStart(2, "0")}`,
|
|
246
|
+
pea_type: prefix,
|
|
247
|
+
growth_habit: "bush",
|
|
248
|
+
days_to_maturity: 60,
|
|
249
|
+
notes: "Bounded result test.",
|
|
250
|
+
}));
|
|
251
|
+
const observationDocuments = Array.from({ length: 25 }, (_, index) => ({
|
|
252
|
+
_id: `${prefix}observation-${index}`,
|
|
253
|
+
variety_name: prefix,
|
|
254
|
+
observed_on: `2026-09-${String((index % 9) + 1).padStart(2, "0")}`,
|
|
255
|
+
location: "Test bed",
|
|
256
|
+
growth_stage: `stage-${index}`,
|
|
257
|
+
notes: "Bounded result test.",
|
|
258
|
+
}));
|
|
259
|
+
await varieties.insertMany(varietyDocuments);
|
|
260
|
+
await observations.insertMany(observationDocuments);
|
|
261
|
+
t.after(async () => {
|
|
262
|
+
await varieties.deleteMany({ _id: { $in: varietyDocuments.map(({ _id }) => _id) } });
|
|
263
|
+
await observations.deleteMany({ _id: { $in: observationDocuments.map(({ _id }) => _id) } });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await database.command({ profile: 0 });
|
|
267
|
+
await database.collection("system.profile").drop().catch(() => {});
|
|
268
|
+
await database.command({ profile: 2, slowms: 0 });
|
|
269
|
+
const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
|
|
270
|
+
environment: { MONGODB_URL: uri },
|
|
271
|
+
});
|
|
272
|
+
const client = await running.connect();
|
|
273
|
+
try {
|
|
274
|
+
const varietyResult = await client.callTool({
|
|
275
|
+
name: "list-pea-varieties",
|
|
276
|
+
arguments: { pea_type: prefix },
|
|
277
|
+
});
|
|
278
|
+
const observationResult = await client.callTool({
|
|
279
|
+
name: "list-pea-observations",
|
|
280
|
+
arguments: { variety_name: prefix },
|
|
281
|
+
});
|
|
282
|
+
assert.equal(varietyResult.structuredContent.varieties.length, 20);
|
|
283
|
+
assert.equal(observationResult.structuredContent.observations.length, 20);
|
|
284
|
+
} finally {
|
|
285
|
+
await database.command({ profile: 0 });
|
|
286
|
+
}
|
|
287
|
+
const profiled = await database.collection("system.profile").find({
|
|
288
|
+
"command.find": { $in: [varietyCollectionName, observationCollectionName] },
|
|
289
|
+
}).toArray();
|
|
290
|
+
assert.deepEqual(
|
|
291
|
+
new Map(profiled.map(({ command }) => [command.find, command.maxTimeMS])),
|
|
292
|
+
new Map([[varietyCollectionName, 1_500], [observationCollectionName, 1_500]]),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("closing the app cleanly releases both collection paths", async () => {
|
|
297
|
+
const { app, closeProvider } = await createMongoExample({ uri });
|
|
298
|
+
await app.ready();
|
|
299
|
+
await app.close();
|
|
300
|
+
await closeProvider();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("an unavailable database fails without connection details", async (t) => {
|
|
304
|
+
const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
|
|
305
|
+
environment: { MONGODB_URL: "mongodb://127.0.0.1:1" },
|
|
306
|
+
});
|
|
307
|
+
assertGenericToolFailure(await rawCall(running.url, "list-pea-varieties", {}));
|
|
308
|
+
assertGenericToolFailure(await rawCall(running.url, "list-pea-observations", {}));
|
|
309
|
+
const readiness = await fetch(new URL("/readyz", running.url));
|
|
310
|
+
assert.equal(readiness.status, 503);
|
|
311
|
+
assert.equal(await readiness.text(), "not ready\n");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
async function rawCall(url, name, arguments_) {
|
|
315
|
+
const response = await fetch(url, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: {
|
|
318
|
+
Accept: "application/json, text/event-stream",
|
|
319
|
+
"Content-Type": "application/json",
|
|
320
|
+
"MCP-Protocol-Version": "2026-07-28",
|
|
321
|
+
"Mcp-Method": "tools/call",
|
|
322
|
+
"Mcp-Name": name,
|
|
323
|
+
},
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
jsonrpc: "2.0",
|
|
326
|
+
id: crypto.randomUUID(),
|
|
327
|
+
method: "tools/call",
|
|
328
|
+
params: { name, arguments: arguments_, _meta: requestMeta },
|
|
329
|
+
}),
|
|
330
|
+
});
|
|
331
|
+
return { response, body: await response.json() };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function assertGenericToolFailure(result) {
|
|
335
|
+
assert.equal(result.response.status, 200);
|
|
336
|
+
assert.equal(result.body.result.content[0].text, "Tool execution failed");
|
|
337
|
+
assert.doesNotMatch(
|
|
338
|
+
JSON.stringify({ ...result.body.result, _meta: undefined }),
|
|
339
|
+
/mongodb|database|connection|ECONNREFUSED|provider/i,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
const [command, ...args] = process.argv.slice(2);
|
|
6
|
+
if (!command) throw new Error("A test command is required");
|
|
7
|
+
|
|
8
|
+
const project = `emseepea-mongodb-test-${process.pid}`;
|
|
9
|
+
const port = await availablePort();
|
|
10
|
+
const composeEnvironment = { ...process.env, MONGODB_PORT: String(port) };
|
|
11
|
+
const databaseUrl = `mongodb://127.0.0.1:${port}`;
|
|
12
|
+
const compose = (...composeArgs) => run(
|
|
13
|
+
"docker",
|
|
14
|
+
["compose", "--project-name", project, ...composeArgs],
|
|
15
|
+
composeEnvironment,
|
|
16
|
+
180_000,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
let exitCode = 1;
|
|
20
|
+
try {
|
|
21
|
+
if (await compose("up", "--detach", "--wait", "database") !== 0) {
|
|
22
|
+
throw new Error("Could not start the MongoDB test service");
|
|
23
|
+
}
|
|
24
|
+
const environment = { ...process.env, MONGODB_URL: databaseUrl };
|
|
25
|
+
if (await run(process.execPath, ["dist/setup-database.js"], environment) !== 0) {
|
|
26
|
+
throw new Error("Could not prepare the MongoDB test database");
|
|
27
|
+
}
|
|
28
|
+
if (basename(command) === "emseepea-test") {
|
|
29
|
+
for (let trial = 1; trial <= 3; trial += 1) {
|
|
30
|
+
const trialEnvironment = {
|
|
31
|
+
...environment,
|
|
32
|
+
MONGODB_URL: `${databaseUrl}/emseepea_trial_${trial}`,
|
|
33
|
+
};
|
|
34
|
+
if (await run(process.execPath, ["dist/setup-database.js"], trialEnvironment) !== 0) {
|
|
35
|
+
throw new Error(`Could not prepare MongoDB semantic trial ${trial}`);
|
|
36
|
+
}
|
|
37
|
+
environment[`MONGODB_URL_TRIAL_${trial}`] = trialEnvironment.MONGODB_URL;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exitCode = await run(command, args, environment);
|
|
41
|
+
} finally {
|
|
42
|
+
await compose("down", "--volumes");
|
|
43
|
+
}
|
|
44
|
+
process.exitCode = exitCode;
|
|
45
|
+
|
|
46
|
+
function run(executable, executableArgs, env, timeout = 600_000) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const child = spawn(executable, executableArgs, { env, stdio: "inherit" });
|
|
49
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
|
|
50
|
+
child.once("error", (error) => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
reject(error);
|
|
53
|
+
});
|
|
54
|
+
child.once("close", (code) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
resolve(code ?? 1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function availablePort() {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const server = createServer();
|
|
64
|
+
server.once("error", reject);
|
|
65
|
+
server.listen(0, "127.0.0.1", () => {
|
|
66
|
+
const address = server.address();
|
|
67
|
+
server.close((error) => {
|
|
68
|
+
if (error) reject(error);
|
|
69
|
+
else if (address && typeof address === "object") resolve(address.port);
|
|
70
|
+
else reject(new Error("Could not choose a MongoDB test port"));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "NodeNext",
|
|
4
|
+
"moduleResolution": "NodeNext",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"target": "ES2023",
|
|
9
|
+
"types": ["node"],
|
|
10
|
+
"verbatimModuleSyntax": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts"]
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@emseepea/create-mongodb-backed-server",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Create an Em See Pea server with schema-enforced and schemaless MongoDB collections.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"starterDependencies": [
|
|
8
|
+
"@emseepea/server",
|
|
9
|
+
"ajv",
|
|
10
|
+
"json-schema-to-ts",
|
|
11
|
+
"mongodb",
|
|
12
|
+
"zod"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "npm run build:example && npm run build:initializer",
|
|
16
|
+
"build:example": "tsc -p tsconfig.json",
|
|
17
|
+
"build:initializer": "node ../../scripts/build-initializer.mjs",
|
|
18
|
+
"db:setup": "node dist/setup-database.js",
|
|
19
|
+
"start": "node dist/server.js",
|
|
20
|
+
"dev": "docker compose up --detach --wait database && npm run build && npm run db:setup && npm start",
|
|
21
|
+
"test": "npm run build && npm run test:built",
|
|
22
|
+
"test:built": "node test/with-mongodb.mjs node --test test/*.test.mjs",
|
|
23
|
+
"test:llm": "npm run build && npm run test:llm:built",
|
|
24
|
+
"test:llm:built": "node test/with-mongodb.mjs emseepea-test eval",
|
|
25
|
+
"lint": "oxlint src test eval",
|
|
26
|
+
"prepack": "npm run build:initializer"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@emseepea/server": "0.3.3",
|
|
30
|
+
"ajv": "8.20.0",
|
|
31
|
+
"json-schema-to-ts": "3.1.1",
|
|
32
|
+
"mongodb": "7.6.0",
|
|
33
|
+
"zod": "4.4.3",
|
|
34
|
+
"@emseepea/testing": "0.5.3",
|
|
35
|
+
"@types/node": "24.13.3",
|
|
36
|
+
"typescript": "6.0.3",
|
|
37
|
+
"oxlint": "1.80.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.13.0"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/emseepea/emseepea.git",
|
|
45
|
+
"directory": "examples/mongodb-backed-server"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://emseepea.github.io/emseepea/examples/",
|
|
48
|
+
"bugs": "https://github.com/emseepea/emseepea/issues",
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"provenance": true
|
|
52
|
+
},
|
|
53
|
+
"bin": {
|
|
54
|
+
"create-mongodb-backed-server": "./initializer-dist/create.mjs"
|
|
55
|
+
},
|
|
56
|
+
"files": [
|
|
57
|
+
"initializer-dist"
|
|
58
|
+
]
|
|
59
|
+
}
|