@emseepea/create-database-schema-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 ADDED
@@ -0,0 +1,105 @@
1
+ # `@emseepea/create-database-schema-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 PostgreSQL already defines the internal data contract
9
+ and you want generated TypeScript and runtime validation to follow it. It shows
10
+ reads and writes through a view, with one stored procedure as a secondary
11
+ integration example.
12
+
13
+ Choose the [API-backed server](../api-backed-server/README.md) for an HTTP API,
14
+ or the [multi-instance PostgreSQL server](../multi-instance-postgres-server/README.md)
15
+ when shared state across interchangeable processes is the main lesson.
16
+ [Compare all templates](https://emseepea.github.io/emseepea/examples/).
17
+
18
+ ## Create a Project
19
+
20
+ ```sh
21
+ npm init @emseepea/database-schema-server -- my-server
22
+ ```
23
+
24
+ The command creates a standalone app in a new `my-server` directory and sets
25
+ `private: true` in its `package.json` so the app cannot be published by
26
+ accident. It includes the database schema, checked-in generated code, Docker
27
+ Compose setup, lint checks, ordinary tests, and semantic tests.
28
+
29
+ <!-- generated-project-readme -->
30
+
31
+ ## Database Schema Server
32
+
33
+ PostgreSQL is authoritative for the internal contract. Kanel and Kanel Zod
34
+ generate TypeScript and runtime validators from the committed schema. Normal
35
+ builds and starts use the checked-in generated files, so they do not need a
36
+ live database.
37
+
38
+ The MCP schemas are small, public, and described for an AI. Database rows are
39
+ validated with generated schemas at the integration boundary. New compatible
40
+ database values, such as a new pea type, pass through without an application
41
+ release.
42
+
43
+ ## Run Locally
44
+
45
+ You need Node.js 22 or 24 and Docker Compose.
46
+
47
+ ```sh
48
+ npm install
49
+ npm run dev
50
+ ```
51
+
52
+ Stop the server with Control-C. Remove the local database and volume when you
53
+ no longer need them:
54
+
55
+ ```sh
56
+ docker compose down --volumes
57
+ ```
58
+
59
+ ## Change the Database Contract
60
+
61
+ Edit `schema.sql`, recreate the development database, then regenerate the
62
+ checked-in files:
63
+
64
+ ```sh
65
+ docker compose down --volumes
66
+ docker compose up --detach --wait database
67
+ npm run generate
68
+ ```
69
+
70
+ Review the generated diff before committing it. CI recreates PostgreSQL from
71
+ `schema.sql`, regenerates the files, and fails if they differ.
72
+
73
+ Views are the preferred application boundary because they support ordinary
74
+ reads and writes while insulating tools from storage tables. The
75
+ `summarize-pea-catalog` procedure is included only for databases where an
76
+ existing operation must remain procedural.
77
+
78
+ ## Tools
79
+
80
+ - `list-pea-varieties` reads a bounded, fixed projection from the catalogue view.
81
+ - `add-pea-variety` writes through the updatable catalogue view.
82
+ - `summarize-pea-catalog` calls the secondary stored procedure.
83
+
84
+ The tools never accept SQL, operators, table names, sort expressions, or
85
+ destinations from the caller. PostgreSQL statements have a 1.5 second timeout,
86
+ the connection pool is bounded, and provider failures return a generic error.
87
+
88
+ ## Check This Project
89
+
90
+ Run generation, lint, build, and ordinary integration tests without spending
91
+ model tokens:
92
+
93
+ ```sh
94
+ npm run generate:check
95
+ npm run lint
96
+ npm test
97
+ ```
98
+
99
+ Run the more expensive AI test separately:
100
+
101
+ ```sh
102
+ npm run test:llm
103
+ ```
104
+
105
+ 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,75 @@
1
+ # Database Schema Server
2
+
3
+ PostgreSQL is authoritative for the internal contract. Kanel and Kanel Zod
4
+ generate TypeScript and runtime validators from the committed schema. Normal
5
+ builds and starts use the checked-in generated files, so they do not need a
6
+ live database.
7
+
8
+ The MCP schemas are small, public, and described for an AI. Database rows are
9
+ validated with generated schemas at the integration boundary. New compatible
10
+ database values, such as a new pea type, pass through without an application
11
+ release.
12
+
13
+ ## Run Locally
14
+
15
+ You need Node.js 22 or 24 and Docker Compose.
16
+
17
+ ```sh
18
+ npm install
19
+ npm run dev
20
+ ```
21
+
22
+ Stop the server with Control-C. Remove the local database and volume when you
23
+ no longer need them:
24
+
25
+ ```sh
26
+ docker compose down --volumes
27
+ ```
28
+
29
+ ## Change the Database Contract
30
+
31
+ Edit `schema.sql`, recreate the development database, then regenerate the
32
+ checked-in files:
33
+
34
+ ```sh
35
+ docker compose down --volumes
36
+ docker compose up --detach --wait database
37
+ npm run generate
38
+ ```
39
+
40
+ Review the generated diff before committing it. CI recreates PostgreSQL from
41
+ `schema.sql`, regenerates the files, and fails if they differ.
42
+
43
+ Views are the preferred application boundary because they support ordinary
44
+ reads and writes while insulating tools from storage tables. The
45
+ `summarize-pea-catalog` procedure is included only for databases where an
46
+ existing operation must remain procedural.
47
+
48
+ ## Tools
49
+
50
+ - `list-pea-varieties` reads a bounded, fixed projection from the catalogue view.
51
+ - `add-pea-variety` writes through the updatable catalogue view.
52
+ - `summarize-pea-catalog` calls the secondary stored procedure.
53
+
54
+ The tools never accept SQL, operators, table names, sort expressions, or
55
+ destinations from the caller. PostgreSQL statements have a 1.5 second timeout,
56
+ the connection pool is bounded, and provider failures return a generic error.
57
+
58
+ ## Check This Project
59
+
60
+ Run generation, lint, build, and ordinary integration tests without spending
61
+ model tokens:
62
+
63
+ ```sh
64
+ npm run generate:check
65
+ npm run lint
66
+ npm test
67
+ ```
68
+
69
+ Run the more expensive AI test separately:
70
+
71
+ ```sh
72
+ npm run test:llm
73
+ ```
74
+
75
+ If Claude is not already signed in, run `claude auth login` first.
@@ -0,0 +1,20 @@
1
+ services:
2
+ database:
3
+ image: postgres:18.6-alpine3.23
4
+ environment:
5
+ POSTGRES_DB: emseepea
6
+ POSTGRES_PASSWORD: emseepea
7
+ POSTGRES_USER: emseepea
8
+ healthcheck:
9
+ test: ["CMD-SHELL", "pg_isready -U emseepea -d emseepea"]
10
+ interval: 1s
11
+ timeout: 3s
12
+ retries: 20
13
+ ports:
14
+ - "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
15
+ volumes:
16
+ - postgres-data:/var/lib/postgresql
17
+ - ./schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
18
+
19
+ volumes:
20
+ postgres-data:
@@ -0,0 +1,48 @@
1
+ import test from "node:test";
2
+ import {
3
+ assertResponseContains,
4
+ assertResponseMeaning,
5
+ assertToolCalls,
6
+ createConversation,
7
+ } from "@emseepea/testing/semantic";
8
+
9
+ const trialDatabaseUrls = [1, 2, 3].map((trial) => {
10
+ const value = process.env[`DATABASE_URL_TRIAL_${trial}`];
11
+ if (!value) throw new Error(`DATABASE_URL_TRIAL_${trial} is required`);
12
+ return value;
13
+ });
14
+
15
+ test("finds and adds 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) => ({ DATABASE_URL: trialDatabaseUrls[trial - 1] }),
19
+ });
20
+
21
+ // One read and one write show both important selection decisions. Procedure
22
+ // selection stays in ordinary tests because a third model turn adds cost but
23
+ // does not teach a meaningfully different MCP interaction.
24
+ const fastest = await chat.send("Which snap pea variety matures fastest?");
25
+ assertToolCalls(fastest, [{
26
+ name: "list-pea-varieties",
27
+ arguments: { pea_type: "snap" },
28
+ }]);
29
+ await assertResponseMeaning(fastest, {
30
+ expected: "Sugar Ann is the fastest listed snap pea variety and matures in 56 days.",
31
+ });
32
+
33
+ const added = await chat.send(
34
+ "Add Golden Sweet as a climbing mangetout pea that matures in 70 days. " +
35
+ "Use exactly mangetout as its pea type. Its notes are: Purple flowers and flat edible pods.",
36
+ );
37
+ assertToolCalls(added, [{
38
+ name: "add-pea-variety",
39
+ arguments: {
40
+ name: "Golden Sweet",
41
+ pea_type: "mangetout",
42
+ growth_habit: "climbing",
43
+ days_to_maturity: 70,
44
+ notes: "Purple flowers and flat edible pods.",
45
+ },
46
+ }]);
47
+ assertResponseContains(added, ["Golden Sweet", "mangetout", "70"]);
48
+ });
@@ -0,0 +1,18 @@
1
+ const { makePgTsGenerator } = require("kanel");
2
+ const { generateZodSchemas } = require("kanel-zod");
3
+
4
+ module.exports = {
5
+ connection: process.env.DATABASE_URL ?? "postgres://emseepea:emseepea@127.0.0.1:5432/emseepea",
6
+ schemaNames: ["public"],
7
+ resolveViews: true,
8
+ outputPath: "./src/generated",
9
+ preDeleteOutputFolder: true,
10
+ typescriptConfig: {
11
+ enumStyle: "literal-union",
12
+ tsModuleFormat: "explicit-esm",
13
+ },
14
+ generators: [makePgTsGenerator({
15
+ filter: ({ kind }) => kind !== "procedure",
16
+ preRenderHooks: [generateZodSchemas],
17
+ })],
18
+ };
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "emseepea-starter",
3
+ "version": "0.0.0",
4
+ "description": "Create an Em See Pea server with internal validation generated from a PostgreSQL schema.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json",
9
+ "generate": "kanel --config kanel.config.cjs",
10
+ "generate:check": "node test/with-postgres.mjs node scripts/check-generated.mjs",
11
+ "start": "node dist/server.js",
12
+ "dev": "docker compose up --detach --wait database && npm run generate && npm run build && npm start",
13
+ "test": "npm run build && npm run test:built",
14
+ "test:built": "node test/with-postgres.mjs node --test test/*.test.mjs",
15
+ "test:llm": "npm run build && npm run test:llm:built",
16
+ "test:llm:built": "node test/with-postgres.mjs emseepea-test eval",
17
+ "lint": "oxlint src test eval scripts kanel.config.cjs"
18
+ },
19
+ "devDependencies": {
20
+ "kanel": "4.0.4",
21
+ "kanel-zod": "4.0.1",
22
+ "@emseepea/testing": "0.5.2",
23
+ "@modelcontextprotocol/client": "2.0.0",
24
+ "@types/node": "24.13.3",
25
+ "@types/pg": "8.23.1",
26
+ "typescript": "6.0.3",
27
+ "oxlint": "1.80.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.13.0"
31
+ },
32
+ "private": true,
33
+ "dependencies": {
34
+ "@emseepea/server": "0.3.3",
35
+ "pg": "8.23.0",
36
+ "zod": "4.4.3"
37
+ }
38
+ }
@@ -0,0 +1,46 @@
1
+ CREATE TYPE pea_catalog_summary AS (
2
+ pea_type text,
3
+ variety_count integer,
4
+ fastest_days_to_maturity integer
5
+ );
6
+
7
+ CREATE TABLE pea_varieties (
8
+ name text PRIMARY KEY,
9
+ pea_type text NOT NULL,
10
+ growth_habit text NOT NULL,
11
+ days_to_maturity integer NOT NULL CHECK (days_to_maturity > 0),
12
+ notes text NOT NULL DEFAULT ''
13
+ );
14
+
15
+ CREATE VIEW pea_variety_catalog AS
16
+ SELECT name, pea_type, growth_habit, days_to_maturity, notes
17
+ FROM pea_varieties;
18
+
19
+ CREATE PROCEDURE summarize_pea_catalog(
20
+ IN selected_pea_type text,
21
+ INOUT summary pea_catalog_summary
22
+ )
23
+ LANGUAGE plpgsql
24
+ AS $$
25
+ BEGIN
26
+ SELECT
27
+ COALESCE(selected_pea_type, 'all'),
28
+ COUNT(*)::integer,
29
+ MIN(days_to_maturity)::integer
30
+ INTO summary.pea_type, summary.variety_count, summary.fastest_days_to_maturity
31
+ FROM pea_variety_catalog
32
+ WHERE selected_pea_type IS NULL OR pea_type = selected_pea_type;
33
+ END;
34
+ $$;
35
+
36
+ INSERT INTO pea_variety_catalog
37
+ (name, pea_type, growth_habit, days_to_maturity, notes)
38
+ VALUES
39
+ ('Green Arrow', 'shelling', 'climbing', 68, 'Long pods with sweet peas.'),
40
+ ('Sugar Ann', 'snap', 'bush', 56, 'Compact plants with edible pods.'),
41
+ ('Oregon Sugar Pod II', 'snow', 'bush', 65, 'Flat edible pods.');
42
+
43
+ COMMENT ON VIEW pea_variety_catalog IS
44
+ 'Stable read and write boundary for the pea variety catalogue.';
45
+ COMMENT ON TYPE pea_catalog_summary IS
46
+ 'Summary returned by the secondary stored-procedure example.';
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile, readdir } from "node:fs/promises";
3
+
4
+ import { processDatabase } from "kanel";
5
+ import config from "../kanel.config.cjs";
6
+
7
+ const generated = new URL("../src/generated/", import.meta.url);
8
+ const before = await snapshot(generated);
9
+ assert.ok(Object.keys(before).length > 0, "checked-in generated files are missing");
10
+ await processDatabase(config);
11
+ assert.deepEqual(await snapshot(generated), before, "database-generated files are stale");
12
+
13
+ async function snapshot(directory, prefix = "") {
14
+ const result = {};
15
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
16
+ const path = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, directory);
17
+ const name = `${prefix}${entry.name}`;
18
+ if (entry.isDirectory()) Object.assign(result, await snapshot(path, `${name}/`));
19
+ else result[name] = await readFile(path, "utf8");
20
+ }
21
+ return result;
22
+ }
@@ -0,0 +1,53 @@
1
+ import { createEmseepea, discoverCapabilities } from "@emseepea/server";
2
+ import { Pool } from "pg";
3
+ import { z } from "zod";
4
+
5
+ export interface DatabaseSchemaExampleOptions {
6
+ readonly databaseUrl: string;
7
+ }
8
+
9
+ export async function createDatabaseSchemaExample(options: DatabaseSchemaExampleOptions) {
10
+ const databaseUrl = z.string().url().refine(
11
+ (value) => ["postgres:", "postgresql:"].includes(new URL(value).protocol),
12
+ "databaseUrl must use PostgreSQL",
13
+ ).parse(options.databaseUrl);
14
+ let database: Pool | undefined = new Pool({
15
+ connectionString: databaseUrl,
16
+ connectionTimeoutMillis: 2_000,
17
+ idleTimeoutMillis: 10_000,
18
+ max: 4,
19
+ query_timeout: 2_000,
20
+ statement_timeout: 1_500,
21
+ });
22
+ database.on("error", () => {
23
+ // Readiness and tool calls report provider failure without exposing details.
24
+ });
25
+
26
+ const app = createEmseepea({
27
+ name: "emseepea-database-schema-server",
28
+ version: "0.0.0",
29
+ instructions: "Read, add, and summarize pea varieties in a database-backed catalogue.",
30
+ readiness: async ({ signal }) => {
31
+ if (!database) return false;
32
+ try {
33
+ signal.throwIfAborted();
34
+ await database.query("SELECT 1 FROM pea_variety_catalog LIMIT 1");
35
+ signal.throwIfAborted();
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ },
41
+ readinessTimeoutMs: 2_500,
42
+ ...await discoverCapabilities(new URL("./capabilities/", import.meta.url), {
43
+ database: () => database,
44
+ }),
45
+ });
46
+ const closeProvider = async () => {
47
+ const activeDatabase = database;
48
+ database = undefined;
49
+ await activeDatabase?.end();
50
+ };
51
+ app.addHook("onClose", closeProvider);
52
+ return { app, closeProvider };
53
+ }
@@ -0,0 +1,5 @@
1
+ import type { Pool } from "pg";
2
+
3
+ export interface DatabaseSchemaContext {
4
+ readonly database: () => Pool | undefined;
5
+ }
@@ -0,0 +1,37 @@
1
+ import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
2
+ import { peaVarietyCatalog } from "../generated/public/PeaVarietyCatalog.mjs";
3
+ import { varietySchema } from "../pea-variety.js";
4
+ import type { DatabaseSchemaContext } from "./context.js";
5
+
6
+ const inputSchema = varietySchema;
7
+ const outputSchema = varietySchema;
8
+
9
+ export default ((context) => defineTool({
10
+ name: "add-pea-variety",
11
+ access: "public",
12
+ description: "Add one pea variety to the catalogue.",
13
+ inputSchema,
14
+ outputSchema,
15
+ async handler(variety, { signal }) {
16
+ const database = context.database();
17
+ if (!database) throw new Error("Variety provider unavailable");
18
+ signal.throwIfAborted();
19
+ const result = await database.query({
20
+ text: `
21
+ INSERT INTO pea_variety_catalog
22
+ (name, pea_type, growth_habit, days_to_maturity, notes)
23
+ VALUES ($1, $2, $3, $4, $5)
24
+ RETURNING name, pea_type, growth_habit, days_to_maturity, notes
25
+ `,
26
+ values: [
27
+ variety.name,
28
+ variety.pea_type,
29
+ variety.growth_habit,
30
+ variety.days_to_maturity,
31
+ variety.notes,
32
+ ],
33
+ });
34
+ signal.throwIfAborted();
35
+ return { data: peaVarietyCatalog.parse(result.rows[0]) };
36
+ },
37
+ })) satisfies CapabilityModuleFactory<DatabaseSchemaContext>;
@@ -0,0 +1,39 @@
1
+ import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
2
+ import { z } from "zod";
3
+ import { peaVarietyCatalog } from "../generated/public/PeaVarietyCatalog.mjs";
4
+ import { varietySchema } from "../pea-variety.js";
5
+ import type { DatabaseSchemaContext } from "./context.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("Matching pea varieties ordered by name."),
13
+ });
14
+
15
+ export default ((context) => defineTool({
16
+ name: "list-pea-varieties",
17
+ access: "public",
18
+ description: "List pea varieties, optionally filtered by pea type.",
19
+ inputSchema,
20
+ outputSchema,
21
+ async handler({ pea_type }, { signal }) {
22
+ const database = context.database();
23
+ if (!database) throw new Error("Variety provider unavailable");
24
+ signal.throwIfAborted();
25
+ const result = await database.query({
26
+ text: `
27
+ SELECT name, pea_type, growth_habit, days_to_maturity, notes
28
+ FROM pea_variety_catalog
29
+ WHERE ($1::text IS NULL OR pea_type = $1)
30
+ ORDER BY name
31
+ LIMIT 20
32
+ `,
33
+ values: [pea_type ?? null],
34
+ });
35
+ signal.throwIfAborted();
36
+ const varieties = z.array(peaVarietyCatalog).max(20).parse(result.rows);
37
+ return { data: { varieties } };
38
+ },
39
+ })) satisfies CapabilityModuleFactory<DatabaseSchemaContext>;
@@ -0,0 +1,44 @@
1
+ import { defineTool, type CapabilityModuleFactory } from "@emseepea/server";
2
+ import { z } from "zod";
3
+ import { peaCatalogSummary } from "../generated/public/PeaCatalogSummary.mjs";
4
+ import type { DatabaseSchemaContext } from "./context.js";
5
+
6
+ const inputSchema = z.object({
7
+ pea_type: z.string().trim().min(1).max(40).optional()
8
+ .describe("Optional pea type to summarize. Omit it to summarize the whole catalogue."),
9
+ });
10
+ const outputSchema = z.object({
11
+ pea_type: z.string().nullable().describe("Requested pea type, or all for an unfiltered summary."),
12
+ variety_count: z.number().int().nonnegative().describe("Number of matching pea varieties."),
13
+ fastest_days_to_maturity: z.number().int().positive().nullable()
14
+ .describe("Fewest days to maturity among matching varieties, or null when none match."),
15
+ });
16
+
17
+ export default ((context) => defineTool({
18
+ name: "summarize-pea-catalog",
19
+ access: "public",
20
+ description: "Summarize the pea catalogue.",
21
+ inputSchema,
22
+ outputSchema,
23
+ async handler({ pea_type }, { signal }) {
24
+ const database = context.database();
25
+ if (!database) throw new Error("Variety provider unavailable");
26
+ signal.throwIfAborted();
27
+ const client = await database.connect();
28
+ try {
29
+ const called = await client.query({
30
+ text: "CALL summarize_pea_catalog($1, NULL)",
31
+ values: [pea_type ?? null],
32
+ });
33
+ signal.throwIfAborted();
34
+ const expanded = await client.query({
35
+ text: "SELECT ($1::pea_catalog_summary).*",
36
+ values: [called.rows[0]?.summary],
37
+ });
38
+ signal.throwIfAborted();
39
+ return { data: outputSchema.parse(peaCatalogSummary.parse(expanded.rows[0])) };
40
+ } finally {
41
+ client.release();
42
+ }
43
+ },
44
+ })) satisfies CapabilityModuleFactory<DatabaseSchemaContext>;
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Represents the compositeType public.pea_catalog_summary
5
+ * Summary returned by the secondary stored-procedure example.
6
+ */
7
+ export default interface PeaCatalogSummary {
8
+ pea_type: string | null;
9
+
10
+ variety_count: number | null;
11
+
12
+ fastest_days_to_maturity: number | null;
13
+ }
14
+
15
+ export const peaCatalogSummary = z.object({
16
+ pea_type: z.string().nullable(),
17
+ variety_count: z.number().nullable(),
18
+ fastest_days_to_maturity: z.number().nullable(),
19
+ }) as unknown as z.Schema<PeaCatalogSummary>;
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Identifier type for public.pea_varieties */
4
+ export type PeaVarietiesName = string & { __brand: 'public.pea_varieties' };
5
+
6
+ /** Represents the table public.pea_varieties */
7
+ export default interface PeaVarieties {
8
+ name: PeaVarietiesName;
9
+
10
+ pea_type: string;
11
+
12
+ growth_habit: string;
13
+
14
+ days_to_maturity: number;
15
+
16
+ notes: string;
17
+ }
18
+
19
+ /** Represents the initializer for the table public.pea_varieties */
20
+ export interface PeaVarietiesInitializer {
21
+ name: PeaVarietiesName;
22
+
23
+ pea_type: string;
24
+
25
+ growth_habit: string;
26
+
27
+ days_to_maturity: number;
28
+
29
+ /** Default value: ''::text */
30
+ notes?: string;
31
+ }
32
+
33
+ /** Represents the mutator for the table public.pea_varieties */
34
+ export interface PeaVarietiesMutator {
35
+ name?: PeaVarietiesName;
36
+
37
+ pea_type?: string;
38
+
39
+ growth_habit?: string;
40
+
41
+ days_to_maturity?: number;
42
+
43
+ notes?: string;
44
+ }
45
+
46
+ export const peaVarietiesName = z.string() as unknown as z.Schema<PeaVarietiesName>;
47
+
48
+ export const peaVarieties = z.object({
49
+ name: peaVarietiesName,
50
+ pea_type: z.string(),
51
+ growth_habit: z.string(),
52
+ days_to_maturity: z.number(),
53
+ notes: z.string(),
54
+ }) as unknown as z.Schema<PeaVarieties>;
55
+
56
+ export const peaVarietiesInitializer = z.object({
57
+ name: peaVarietiesName,
58
+ pea_type: z.string(),
59
+ growth_habit: z.string(),
60
+ days_to_maturity: z.number(),
61
+ notes: z.string().optional(),
62
+ }) as unknown as z.Schema<PeaVarietiesInitializer>;
63
+
64
+ export const peaVarietiesMutator = z.object({
65
+ name: peaVarietiesName.optional(),
66
+ pea_type: z.string().optional(),
67
+ growth_habit: z.string().optional(),
68
+ days_to_maturity: z.number().optional(),
69
+ notes: z.string().optional(),
70
+ }) as unknown as z.Schema<PeaVarietiesMutator>;
@@ -0,0 +1,26 @@
1
+ import { peaVarietiesName, type PeaVarietiesName } from './PeaVarieties.mjs';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Represents the view public.pea_variety_catalog
6
+ * Stable read and write boundary for the pea variety catalogue.
7
+ */
8
+ export default interface PeaVarietyCatalog {
9
+ name: PeaVarietiesName;
10
+
11
+ pea_type: string;
12
+
13
+ growth_habit: string;
14
+
15
+ days_to_maturity: number;
16
+
17
+ notes: string;
18
+ }
19
+
20
+ export const peaVarietyCatalog = z.object({
21
+ name: peaVarietiesName,
22
+ pea_type: z.string(),
23
+ growth_habit: z.string(),
24
+ days_to_maturity: z.number(),
25
+ notes: z.string(),
26
+ }) as unknown as z.Schema<PeaVarietyCatalog>;
@@ -0,0 +1,9 @@
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().positive().describe("Typical number of days from sowing to harvest."),
8
+ notes: z.string().max(500).describe("Short growing or eating notes."),
9
+ });
@@ -0,0 +1,21 @@
1
+ import { serveEmseepea } from "@emseepea/server";
2
+ import { createDatabaseSchemaExample } from "./app.js";
3
+
4
+ const databaseUrl = process.env.DATABASE_URL ?? "postgres://emseepea:emseepea@127.0.0.1:5432/emseepea";
5
+ const { app } = await createDatabaseSchemaExample({ databaseUrl });
6
+ const running = await serveEmseepea(app, {
7
+ port: Number.parseInt(process.env.PORT ?? "3000", 10),
8
+ });
9
+
10
+ console.log(`Em See Pea database-schema-server 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,210 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { after } from "node:test";
3
+
4
+ import { startMcpServer } from "@emseepea/testing";
5
+ import { Pool } from "pg";
6
+
7
+ const databaseUrl = process.env.DATABASE_URL;
8
+ assert.ok(databaseUrl, "DATABASE_URL is required");
9
+ const database = new Pool({ connectionString: databaseUrl, max: 2 });
10
+ after(() => database.end());
11
+ const requestMeta = {
12
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
13
+ "io.modelcontextprotocol/clientInfo": { name: "database-schema-test", version: "0.0.0" },
14
+ "io.modelcontextprotocol/clientCapabilities": {},
15
+ };
16
+
17
+ test("reads and writes through the view and summarizes through the procedure", async (t) => {
18
+ const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
19
+ environment: { DATABASE_URL: databaseUrl },
20
+ });
21
+ const client = await running.connect();
22
+
23
+ const listed = await client.callTool({
24
+ name: "list-pea-varieties",
25
+ arguments: { pea_type: "snap" },
26
+ });
27
+ assert.equal(listed.isError, false);
28
+ assert.deepEqual(listed.structuredContent, {
29
+ varieties: [{
30
+ name: "Sugar Ann",
31
+ pea_type: "snap",
32
+ growth_habit: "bush",
33
+ days_to_maturity: 56,
34
+ notes: "Compact plants with edible pods.",
35
+ }],
36
+ });
37
+
38
+ const newVariety = {
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
+ const added = await client.callTool({ name: "add-pea-variety", arguments: newVariety });
46
+ assert.equal(added.isError, false);
47
+ assert.deepEqual(added.structuredContent, newVariety);
48
+
49
+ const passedThrough = await client.callTool({
50
+ name: "list-pea-varieties",
51
+ arguments: { pea_type: "mangetout" },
52
+ });
53
+ assert.deepEqual(passedThrough.structuredContent, { varieties: [newVariety] });
54
+
55
+ const summary = await client.callTool({
56
+ name: "summarize-pea-catalog",
57
+ arguments: { pea_type: "snap" },
58
+ });
59
+ assert.equal(summary.isError, false);
60
+ assert.deepEqual(summary.structuredContent, {
61
+ pea_type: "snap",
62
+ variety_count: 1,
63
+ fastest_days_to_maturity: 56,
64
+ });
65
+
66
+ const injected = await client.callTool({
67
+ name: "list-pea-varieties",
68
+ arguments: { pea_type: "snap' OR true --" },
69
+ });
70
+ assert.deepEqual(injected.structuredContent, { varieties: [] });
71
+ assert.doesNotMatch(
72
+ `${running.output().stdout}\n${running.output().stderr}`,
73
+ new RegExp(escapeRegExp(databaseUrl)),
74
+ );
75
+ });
76
+
77
+ test("the catalogue read has a fixed 20-row boundary", async (t) => {
78
+ await database.query(`
79
+ INSERT INTO pea_variety_catalog
80
+ (name, pea_type, growth_habit, days_to_maturity, notes)
81
+ SELECT 'Boundary ' || LPAD(number::text, 2, '0'), 'bounded', 'bush', 60, ''
82
+ FROM generate_series(1, 25) AS number
83
+ `);
84
+ t.after(() => database.query("DELETE FROM pea_variety_catalog WHERE pea_type = 'bounded'"));
85
+
86
+ const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
87
+ environment: { DATABASE_URL: databaseUrl },
88
+ });
89
+ const client = await running.connect();
90
+ const result = await client.callTool({
91
+ name: "list-pea-varieties",
92
+ arguments: { pea_type: "bounded" },
93
+ });
94
+
95
+ assert.equal(result.isError, false);
96
+ assert.equal(result.structuredContent.varieties.length, 20);
97
+ });
98
+
99
+ test("advertises bounded and described public schemas", async (t) => {
100
+ const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
101
+ environment: { DATABASE_URL: databaseUrl },
102
+ });
103
+ const client = await running.connect();
104
+ const { tools } = await client.listTools();
105
+
106
+ assert.deepEqual(tools.map(({ name }) => name), [
107
+ "add-pea-variety",
108
+ "list-pea-varieties",
109
+ "summarize-pea-catalog",
110
+ ]);
111
+ const add = tools[0];
112
+ const list = tools[1];
113
+ const summary = tools[2];
114
+ for (const property of ["name", "pea_type", "growth_habit", "days_to_maturity", "notes"]) {
115
+ assert.ok(add.inputSchema.properties[property].description, `${property} input needs a description`);
116
+ assert.ok(add.outputSchema.properties[property].description, `${property} output needs a description`);
117
+ }
118
+ assert.deepEqual(Object.keys(list.inputSchema.properties), ["pea_type"]);
119
+ assert.ok(list.inputSchema.properties.pea_type.description);
120
+ assert.ok(list.outputSchema.properties.varieties.description);
121
+ assert.ok(summary.inputSchema.properties.pea_type.description);
122
+ assert.ok(summary.outputSchema.properties.variety_count.description);
123
+ assert.ok(summary.outputSchema.properties.fastest_days_to_maturity.description);
124
+ });
125
+
126
+ test("generated validation blocks an invalid database row at the tool boundary", async (t) => {
127
+ const schema = `invalid_output_${process.pid}`;
128
+ await database.query(`CREATE SCHEMA ${schema}`);
129
+ try {
130
+ await database.query(`
131
+ CREATE VIEW ${schema}.pea_variety_catalog AS
132
+ SELECT name, pea_type, growth_habit, days_to_maturity::text, notes
133
+ FROM public.pea_variety_catalog
134
+ `);
135
+ const invalidUrl = new URL(databaseUrl);
136
+ invalidUrl.searchParams.set("options", `-c search_path=${schema}`);
137
+ const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
138
+ environment: { DATABASE_URL: invalidUrl.href },
139
+ });
140
+ assertGenericToolFailure(await rawCall(running.url, "list-pea-varieties", {}));
141
+ } finally {
142
+ await database.query(`DROP SCHEMA ${schema} CASCADE`);
143
+ }
144
+ });
145
+
146
+ test("an unavailable database fails without exposing provider details", async (t) => {
147
+ const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
148
+ environment: { DATABASE_URL: "postgres://emseepea:emseepea@127.0.0.1:1/emseepea" },
149
+ });
150
+ const result = await rawCall(running.url, "list-pea-varieties", {});
151
+ assertGenericToolFailure(result);
152
+ const readiness = await fetch(new URL("/readyz", running.url));
153
+ assert.equal(readiness.status, 503);
154
+ assert.equal(await readiness.text(), "not ready\n");
155
+ assert.doesNotMatch(
156
+ `${running.output().stdout}\n${running.output().stderr}`,
157
+ /postgres:\/\/emseepea:emseepea@127\.0\.0\.1:1\/emseepea/,
158
+ );
159
+ });
160
+
161
+ test("blocked database work stops at the statement timeout", async (t) => {
162
+ const running = await startMcpServer(t, new URL("../dist/server.js", import.meta.url), {
163
+ environment: { DATABASE_URL: databaseUrl },
164
+ });
165
+ const blocker = await database.connect();
166
+ t.after(async () => {
167
+ await blocker.query("ROLLBACK").catch(() => {});
168
+ blocker.release();
169
+ });
170
+ await blocker.query("BEGIN");
171
+ await blocker.query("LOCK TABLE pea_varieties IN ACCESS EXCLUSIVE MODE");
172
+
173
+ const started = Date.now();
174
+ const result = await rawCall(running.url, "list-pea-varieties", {});
175
+ assert.ok(Date.now() - started < 3_000, "blocked query exceeded its bounded timeout");
176
+ assertGenericToolFailure(result);
177
+ });
178
+
179
+ async function rawCall(url, name, arguments_) {
180
+ const response = await fetch(url, {
181
+ method: "POST",
182
+ headers: {
183
+ Accept: "application/json, text/event-stream",
184
+ "Content-Type": "application/json",
185
+ "MCP-Protocol-Version": "2026-07-28",
186
+ "Mcp-Method": "tools/call",
187
+ "Mcp-Name": name,
188
+ },
189
+ body: JSON.stringify({
190
+ jsonrpc: "2.0",
191
+ id: crypto.randomUUID(),
192
+ method: "tools/call",
193
+ params: { name, arguments: arguments_, _meta: requestMeta },
194
+ }),
195
+ });
196
+ return { response, body: await response.json() };
197
+ }
198
+
199
+ function assertGenericToolFailure(result) {
200
+ assert.equal(result.response.status, 200);
201
+ assert.equal(result.body.result.content[0].text, "Tool execution failed");
202
+ assert.doesNotMatch(
203
+ JSON.stringify({ ...result.body.result, _meta: undefined }),
204
+ /postgres|database|connection|ECONNREFUSED|provider/i,
205
+ );
206
+ }
207
+
208
+ function escapeRegExp(value) {
209
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
210
+ }
@@ -0,0 +1,75 @@
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-database-schema-test-${process.pid}`;
9
+ const port = await availablePort();
10
+ const composeEnvironment = { ...process.env, POSTGRES_PORT: String(port) };
11
+ const compose = (...composeArgs) => run(
12
+ "docker",
13
+ ["compose", "--project-name", project, ...composeArgs],
14
+ composeEnvironment,
15
+ 120_000,
16
+ );
17
+
18
+ let exitCode = 1;
19
+ try {
20
+ if (await compose("up", "--detach", "--wait", "database") !== 0) {
21
+ throw new Error("Could not start the PostgreSQL test service");
22
+ }
23
+ const environment = {
24
+ ...process.env,
25
+ DATABASE_URL: `postgres://emseepea:emseepea@127.0.0.1:${port}/emseepea`,
26
+ };
27
+ if (basename(command) === "emseepea-test") {
28
+ for (let trial = 1; trial <= 3; trial += 1) {
29
+ const name = `emseepea_trial_${trial}`;
30
+ if (await compose("exec", "-T", "database", "createdb", "-U", "emseepea", name) !== 0
31
+ || await compose(
32
+ "exec", "-T", "database", "psql", "-U", "emseepea", "-d", name,
33
+ "-v", "ON_ERROR_STOP=1", "-f", "/docker-entrypoint-initdb.d/001-schema.sql",
34
+ ) !== 0) {
35
+ throw new Error(`Could not prepare PostgreSQL semantic trial ${trial}`);
36
+ }
37
+ environment[`DATABASE_URL_TRIAL_${trial}`] =
38
+ `postgres://emseepea:emseepea@127.0.0.1:${port}/${name}`;
39
+ }
40
+ }
41
+ exitCode = await run(command, args, environment);
42
+ } finally {
43
+ await compose("down", "--volumes");
44
+ }
45
+ process.exitCode = exitCode;
46
+
47
+ function run(executable, executableArgs, env, timeout = 600_000) {
48
+ return new Promise((resolve, reject) => {
49
+ const child = spawn(executable, executableArgs, { env, stdio: "inherit" });
50
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
51
+ child.once("error", (error) => {
52
+ clearTimeout(timer);
53
+ reject(error);
54
+ });
55
+ child.once("close", (code) => {
56
+ clearTimeout(timer);
57
+ resolve(code ?? 1);
58
+ });
59
+ });
60
+ }
61
+
62
+ function availablePort() {
63
+ return new Promise((resolve, reject) => {
64
+ const server = createServer();
65
+ server.once("error", reject);
66
+ server.listen(0, "127.0.0.1", () => {
67
+ const address = server.address();
68
+ server.close((error) => {
69
+ if (error) reject(error);
70
+ else if (address && typeof address === "object") resolve(address.port);
71
+ else reject(new Error("Could not choose a PostgreSQL test port"));
72
+ });
73
+ });
74
+ });
75
+ }
@@ -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", "src/**/*.mts"]
13
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@emseepea/create-database-schema-server",
3
+ "version": "0.0.0",
4
+ "description": "Create an Em See Pea server with internal validation generated from a PostgreSQL schema.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "starterDependencies": [
8
+ "@emseepea/server",
9
+ "pg",
10
+ "zod"
11
+ ],
12
+ "scripts": {
13
+ "build": "npm run build:example && npm run build:initializer",
14
+ "build:example": "tsc -p tsconfig.json",
15
+ "build:initializer": "node ../../scripts/build-initializer.mjs",
16
+ "generate": "kanel --config kanel.config.cjs",
17
+ "generate:check": "node test/with-postgres.mjs node scripts/check-generated.mjs",
18
+ "start": "node dist/server.js",
19
+ "dev": "docker compose up --detach --wait database && npm run generate && npm run build && npm start",
20
+ "test": "npm run build && npm run test:built",
21
+ "test:built": "node test/with-postgres.mjs node --test test/*.test.mjs",
22
+ "test:llm": "npm run build && npm run test:llm:built",
23
+ "test:llm:built": "node test/with-postgres.mjs emseepea-test eval",
24
+ "lint": "oxlint src test eval scripts kanel.config.cjs",
25
+ "prepack": "npm run build:initializer"
26
+ },
27
+ "devDependencies": {
28
+ "@emseepea/server": "0.3.3",
29
+ "pg": "8.23.0",
30
+ "zod": "4.4.3",
31
+ "kanel": "4.0.4",
32
+ "kanel-zod": "4.0.1",
33
+ "@emseepea/testing": "0.5.3",
34
+ "@modelcontextprotocol/client": "2.0.0",
35
+ "@types/node": "24.13.3",
36
+ "@types/pg": "8.23.1",
37
+ "typescript": "6.0.3",
38
+ "oxlint": "1.80.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=22.13.0"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/emseepea/emseepea.git",
46
+ "directory": "examples/database-schema-server"
47
+ },
48
+ "homepage": "https://emseepea.github.io/emseepea/examples/",
49
+ "bugs": "https://github.com/emseepea/emseepea/issues",
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "provenance": true
53
+ },
54
+ "bin": {
55
+ "create-database-schema-server": "./initializer-dist/create.mjs"
56
+ },
57
+ "files": [
58
+ "initializer-dist"
59
+ ]
60
+ }