@emseepea/create-api-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 +22 -0
- package/dist/LICENSE +21 -0
- package/dist/create.mjs +44 -0
- package/dist/template/README.md +54 -0
- package/dist/template/eval/meaning.test.mjs +32 -0
- package/dist/template/package.json +30 -0
- package/dist/template/src/app.ts +157 -0
- package/dist/template/src/server.ts +22 -0
- package/dist/template/test/server.test.mjs +132 -0
- package/dist/template/test-support/brewmark-fixture.mjs +26 -0
- package/dist/template/test-support/llm-server.mjs +25 -0
- package/dist/template/tsconfig.json +18 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# `@emseepea/create-api-backed-server`
|
|
2
|
+
|
|
3
|
+
Create a private, standalone Em See Pea project whose tool reads a public web
|
|
4
|
+
API. The package builds its starter from the maintained
|
|
5
|
+
[API-backed server example](https://github.com/emseepea/emseepea/tree/main/examples/backend-no-ui).
|
|
6
|
+
|
|
7
|
+
## Create the Project
|
|
8
|
+
|
|
9
|
+
This initializer is queued for the next pre-alpha release and is not yet
|
|
10
|
+
available from npm.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm init @emseepea/api-backed-server@next -- my-server
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
cd my-server
|
|
18
|
+
npm install
|
|
19
|
+
npm test
|
|
20
|
+
npm run lint
|
|
21
|
+
npm start
|
|
22
|
+
```
|
package/dist/LICENSE
ADDED
|
@@ -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.
|
package/dist/create.mjs
ADDED
|
@@ -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,54 @@
|
|
|
1
|
+
# Public Web Service Backend Example
|
|
2
|
+
|
|
3
|
+
Choose this example when your MCP tool needs to read a public web service and
|
|
4
|
+
translate its data into a clear result for an assistant.
|
|
5
|
+
|
|
6
|
+
This example exposes one read-only `search-coffee-catalog` tool. A normal run
|
|
7
|
+
searches [BrewMark's public coffee catalogue](https://brewmark.io/developers/api-docs)
|
|
8
|
+
and returns at most five coffees.
|
|
9
|
+
|
|
10
|
+
Unlike the [first public tool example](https://github.com/emseepea/emseepea/tree/main/examples/basic-no-ui),
|
|
11
|
+
this tool adapts a separate service. The public input and result use the Model Context Protocol
|
|
12
|
+
(MCP). BrewMark's query and response are checked before the result is returned.
|
|
13
|
+
|
|
14
|
+
The caller can choose a search term and roast filter. The caller cannot change
|
|
15
|
+
the website, path, result limit, sort order, credentials, or HTTP rules. Search
|
|
16
|
+
terms are sent to BrewMark. The example does not send authentication details or
|
|
17
|
+
change data. Do not include personal, secret, or confidential information in a
|
|
18
|
+
search term.
|
|
19
|
+
|
|
20
|
+
The automated checks use invented coffee records through the same MCP server.
|
|
21
|
+
They do not depend on BrewMark being available and do not make a speed or uptime
|
|
22
|
+
claim for BrewMark. Normal runs use BrewMark's fair-use public web service.
|
|
23
|
+
|
|
24
|
+
## Run
|
|
25
|
+
|
|
26
|
+
From this directory:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install
|
|
30
|
+
npm run build
|
|
31
|
+
npm start
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The endpoint is `http://127.0.0.1:3000/mcp`.
|
|
35
|
+
|
|
36
|
+
## Check This Example
|
|
37
|
+
|
|
38
|
+
[Ordinary tests](test/) live in `test/`.
|
|
39
|
+
The [AI tool-choice and understanding test](eval/meaning.test.mjs) lives separately in `eval/`.
|
|
40
|
+
The commands below run each suite independently.
|
|
41
|
+
|
|
42
|
+
Run its build, mapping, validation, and MCP checks:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm test
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Check that Claude chooses catalogue search and understands the rating scales:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npm run test:llm
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If Claude is not already signed in, run `claude auth login` first.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { toolSelectionTest } from "@emseepea/testing/semantic";
|
|
2
|
+
|
|
3
|
+
toolSelectionTest("Coffee ratings retain their documented meaning", {
|
|
4
|
+
server: new URL("../test-support/llm-server.mjs", import.meta.url),
|
|
5
|
+
question:
|
|
6
|
+
"Search the coffee catalogue for natural coffees. Name each returned coffee, " +
|
|
7
|
+
"its roaster and origin, say whether more matches are available, and explain " +
|
|
8
|
+
"exactly what the acidity and body scores mean. Which coffee is more acidic, " +
|
|
9
|
+
"and which has the fuller body?",
|
|
10
|
+
criticalFacts: [
|
|
11
|
+
"Riverlight Natural",
|
|
12
|
+
"North Star Sample Roasters",
|
|
13
|
+
"Burundi",
|
|
14
|
+
"Cedar Grove",
|
|
15
|
+
"Harbour Sample Coffee",
|
|
16
|
+
"Colombia",
|
|
17
|
+
"more matches",
|
|
18
|
+
"low acidity",
|
|
19
|
+
"high acidity",
|
|
20
|
+
"light body",
|
|
21
|
+
"full body"
|
|
22
|
+
],
|
|
23
|
+
criteria:
|
|
24
|
+
"The answer reports Riverlight Natural by North Star Sample Roasters from " +
|
|
25
|
+
"Burundi and Cedar Grove by Harbour Sample Coffee from Colombia. It says more " +
|
|
26
|
+
"matches are available. It explains that acidity runs from 1 for low acidity to " +
|
|
27
|
+
"5 for high acidity, while body runs from 1 for light body to 5 for full body. " +
|
|
28
|
+
"It identifies Riverlight Natural as more acidic and Cedar Grove as having the " +
|
|
29
|
+
"fuller body. It does not treat either score as a quality rating or reverse " +
|
|
30
|
+
"either scale.",
|
|
31
|
+
expectedTools: ["search-coffee-catalog"],
|
|
32
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "emseepea-starter",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json",
|
|
9
|
+
"start": "node dist/server.js",
|
|
10
|
+
"test": "npm run build && npm run test:built",
|
|
11
|
+
"test:built": "node --test test/*.test.mjs",
|
|
12
|
+
"test:llm": "npm run build && npm run test:llm:built",
|
|
13
|
+
"test:llm:built": "emseepea-test eval",
|
|
14
|
+
"lint": "oxlint src test eval test-support"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@emseepea/server": "0.0.4",
|
|
18
|
+
"zod": "4.4.3"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@emseepea/testing": "0.2.1",
|
|
22
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
23
|
+
"@types/node": "24.13.3",
|
|
24
|
+
"typescript": "6.0.3",
|
|
25
|
+
"oxlint": "1.80.0"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createEmseepea, defineMappedTool } from "@emseepea/server";
|
|
2
|
+
import type { JsonHttpClient } from "@emseepea/server/http";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const publicRoast = z.enum(["light", "medium-light", "medium", "medium-dark", "dark"]);
|
|
6
|
+
const brewmarkRoast = z.enum(["LIGHT", "MEDIUM_LIGHT", "MEDIUM", "MEDIUM_DARK", "DARK"]);
|
|
7
|
+
const publicToBrewmarkRoast = {
|
|
8
|
+
light: "LIGHT",
|
|
9
|
+
"medium-light": "MEDIUM_LIGHT",
|
|
10
|
+
medium: "MEDIUM",
|
|
11
|
+
"medium-dark": "MEDIUM_DARK",
|
|
12
|
+
dark: "DARK",
|
|
13
|
+
} as const;
|
|
14
|
+
const brewmarkToPublicRoast = {
|
|
15
|
+
LIGHT: "light",
|
|
16
|
+
MEDIUM_LIGHT: "medium-light",
|
|
17
|
+
MEDIUM: "medium",
|
|
18
|
+
MEDIUM_DARK: "medium-dark",
|
|
19
|
+
DARK: "dark",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const searchInput = z.object({
|
|
23
|
+
query: z.string().trim().min(2).max(80),
|
|
24
|
+
roast: publicRoast.optional(),
|
|
25
|
+
});
|
|
26
|
+
const coffee = z.object({
|
|
27
|
+
name: z.string().max(200),
|
|
28
|
+
roaster: z.string().max(200),
|
|
29
|
+
origin: z.string().max(200).nullable(),
|
|
30
|
+
roast: publicRoast,
|
|
31
|
+
processingMethod: z.string().max(100).nullable(),
|
|
32
|
+
flavourNotes: z.string().max(500).nullable(),
|
|
33
|
+
acidityLevel: z.number().int().min(1).max(5).nullable(),
|
|
34
|
+
bodyLevel: z.number().int().min(1).max(5).nullable(),
|
|
35
|
+
});
|
|
36
|
+
const searchReport = z.object({
|
|
37
|
+
query: z.string().max(80),
|
|
38
|
+
roastFilter: publicRoast.nullable(),
|
|
39
|
+
returnedCount: z.number().int().min(0).max(5),
|
|
40
|
+
moreMatchesAvailable: z.boolean(),
|
|
41
|
+
ratingScale: z.object({
|
|
42
|
+
acidity: z.literal("1 = low acidity; 5 = high acidity"),
|
|
43
|
+
body: z.literal("1 = light body; 5 = full body"),
|
|
44
|
+
}),
|
|
45
|
+
coffees: z.array(coffee).max(5),
|
|
46
|
+
source: z.literal("BrewMark"),
|
|
47
|
+
sourceUrl: z.literal("https://brewmark.io"),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const backendCommand = z.object({
|
|
51
|
+
pathname: z.literal("/api/coffees"),
|
|
52
|
+
searchParams: z.object({
|
|
53
|
+
q: z.string().min(2).max(80),
|
|
54
|
+
roastLevel: brewmarkRoast.optional(),
|
|
55
|
+
sort: z.literal("alpha"),
|
|
56
|
+
limit: z.literal("5"),
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
const backendCoffee = z.object({
|
|
60
|
+
name: z.string().max(200),
|
|
61
|
+
roasterName: z.string().max(200),
|
|
62
|
+
roastLevel: brewmarkRoast,
|
|
63
|
+
origin: z.string().max(200).nullable(),
|
|
64
|
+
processingMethod: z.string().max(100).nullable(),
|
|
65
|
+
flavorProfile: z.string().max(500).nullable(),
|
|
66
|
+
acidityLevel: z.number().int().min(1).max(5).nullable(),
|
|
67
|
+
bodyLevel: z.number().int().min(1).max(5).nullable(),
|
|
68
|
+
});
|
|
69
|
+
const backendPayload = z.object({
|
|
70
|
+
data: z.array(backendCoffee).max(5),
|
|
71
|
+
cursor: z.string().max(2_048).nullable(),
|
|
72
|
+
hasMore: z.boolean(),
|
|
73
|
+
});
|
|
74
|
+
const backendResult = z.object({
|
|
75
|
+
request: backendCommand,
|
|
76
|
+
payload: backendPayload,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export function createBackendExample(client: JsonHttpClient): ReturnType<typeof createEmseepea> {
|
|
80
|
+
const searchCoffeeCatalog = defineMappedTool({
|
|
81
|
+
name: "search-coffee-catalog",
|
|
82
|
+
access: "public",
|
|
83
|
+
description: "Search BrewMark's public coffee catalogue and explain its acidity and body ratings.",
|
|
84
|
+
inputSchema: searchInput,
|
|
85
|
+
outputSchema: searchReport,
|
|
86
|
+
backendInputSchema: backendCommand,
|
|
87
|
+
backendOutputSchema: backendResult,
|
|
88
|
+
mapInput: ({ query, roast }) => ({
|
|
89
|
+
pathname: "/api/coffees" as const,
|
|
90
|
+
searchParams: {
|
|
91
|
+
q: query,
|
|
92
|
+
...(roast ? { roastLevel: publicToBrewmarkRoast[roast] } : {}),
|
|
93
|
+
sort: "alpha" as const,
|
|
94
|
+
limit: "5" as const,
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
async adapter(request, { signal, deadlineMs }) {
|
|
98
|
+
return {
|
|
99
|
+
request,
|
|
100
|
+
payload: await client.get({ ...request, signal, deadlineMs }),
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
mapOutput: ({ request, payload }) => {
|
|
104
|
+
const coffees = payload.data.map((record) => ({
|
|
105
|
+
name: record.name,
|
|
106
|
+
roaster: record.roasterName,
|
|
107
|
+
origin: record.origin,
|
|
108
|
+
roast: brewmarkToPublicRoast[record.roastLevel],
|
|
109
|
+
processingMethod: record.processingMethod,
|
|
110
|
+
flavourNotes: record.flavorProfile,
|
|
111
|
+
acidityLevel: record.acidityLevel,
|
|
112
|
+
bodyLevel: record.bodyLevel,
|
|
113
|
+
}));
|
|
114
|
+
const roastFilter = request.searchParams.roastLevel
|
|
115
|
+
? brewmarkToPublicRoast[request.searchParams.roastLevel]
|
|
116
|
+
: null;
|
|
117
|
+
const data = {
|
|
118
|
+
query: request.searchParams.q,
|
|
119
|
+
roastFilter,
|
|
120
|
+
returnedCount: coffees.length,
|
|
121
|
+
moreMatchesAvailable: payload.hasMore,
|
|
122
|
+
ratingScale: {
|
|
123
|
+
acidity: "1 = low acidity; 5 = high acidity" as const,
|
|
124
|
+
body: "1 = light body; 5 = full body" as const,
|
|
125
|
+
},
|
|
126
|
+
coffees,
|
|
127
|
+
source: "BrewMark" as const,
|
|
128
|
+
sourceUrl: "https://brewmark.io" as const,
|
|
129
|
+
};
|
|
130
|
+
const lines = coffees.map((record) => [
|
|
131
|
+
`${record.name} by ${record.roaster}`,
|
|
132
|
+
`origin: ${record.origin ?? "not provided"}`,
|
|
133
|
+
`roast: ${record.roast}`,
|
|
134
|
+
`acidity: ${record.acidityLevel ?? "not provided"}`,
|
|
135
|
+
`body: ${record.bodyLevel ?? "not provided"}`,
|
|
136
|
+
].join("; "));
|
|
137
|
+
return {
|
|
138
|
+
text: [
|
|
139
|
+
`BrewMark returned ${coffees.length} coffee${coffees.length === 1 ? "" : "s"} for “${data.query}”.`,
|
|
140
|
+
`More matches available: ${data.moreMatchesAvailable ? "yes" : "no"}.`,
|
|
141
|
+
"Acidity: 1 = low acidity; 5 = high acidity.",
|
|
142
|
+
"Body: 1 = light body; 5 = full body.",
|
|
143
|
+
...lines,
|
|
144
|
+
"Source: https://brewmark.io",
|
|
145
|
+
].join("\n"),
|
|
146
|
+
data,
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return createEmseepea({
|
|
152
|
+
name: "emseepea-backend-no-ui",
|
|
153
|
+
version: "0.0.0",
|
|
154
|
+
instructions: "Use search-coffee-catalog to search BrewMark's public coffee catalogue.",
|
|
155
|
+
tools: [searchCoffeeCatalog],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { serveEmseepea } from "@emseepea/server";
|
|
2
|
+
import { createJsonHttpClient } from "@emseepea/server/http";
|
|
3
|
+
import { createBackendExample } from "./app.js";
|
|
4
|
+
|
|
5
|
+
const client = createJsonHttpClient({
|
|
6
|
+
origin: "https://brewmark.io",
|
|
7
|
+
maxResponseBytes: 128 * 1024,
|
|
8
|
+
});
|
|
9
|
+
const running = await serveEmseepea(
|
|
10
|
+
createBackendExample(client),
|
|
11
|
+
{ port: Number.parseInt(process.env.PORT ?? "3000", 10) },
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
console.log(`Em See Pea backend no-UI example listening at ${running.url}`);
|
|
15
|
+
|
|
16
|
+
async function shutdown(): Promise<void> {
|
|
17
|
+
await running.close();
|
|
18
|
+
process.exitCode = 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
process.once("SIGINT", () => void shutdown());
|
|
22
|
+
process.once("SIGTERM", () => void shutdown());
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
4
|
+
import { serveEmseepea } from "@emseepea/server";
|
|
5
|
+
import { createBackendExample } from "../dist/app.js";
|
|
6
|
+
import { brewmarkFixture } from "../test-support/brewmark-fixture.mjs";
|
|
7
|
+
|
|
8
|
+
test("the backend example maps, checks, and explains BrewMark data", async () => {
|
|
9
|
+
const requests = [];
|
|
10
|
+
let response = brewmarkFixture;
|
|
11
|
+
const app = createBackendExample({
|
|
12
|
+
async get(options) {
|
|
13
|
+
requests.push(options);
|
|
14
|
+
if (response instanceof Error) throw response;
|
|
15
|
+
if (response === brewmarkFixture && options.searchParams.roastLevel === "LIGHT") {
|
|
16
|
+
return { ...response, data: response.data.filter(({ roastLevel }) => roastLevel === "LIGHT") };
|
|
17
|
+
}
|
|
18
|
+
return response;
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const running = await serveEmseepea(app, { port: 0 });
|
|
22
|
+
const client = new Client(
|
|
23
|
+
{ name: "backend-example-test", version: "0.0.0" },
|
|
24
|
+
{ versionNegotiation: { mode: { pin: "2026-07-28" } } },
|
|
25
|
+
);
|
|
26
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(running.url)));
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const listed = await client.listTools();
|
|
30
|
+
assert.deepEqual(listed.tools.map(({ name }) => name), ["search-coffee-catalog"]);
|
|
31
|
+
|
|
32
|
+
const invalidInput = await client.callTool({
|
|
33
|
+
name: "search-coffee-catalog",
|
|
34
|
+
arguments: { query: "x" },
|
|
35
|
+
});
|
|
36
|
+
assert.equal(invalidInput.isError, true);
|
|
37
|
+
assert.equal(requests.length, 0);
|
|
38
|
+
|
|
39
|
+
const result = await client.callTool({
|
|
40
|
+
name: "search-coffee-catalog",
|
|
41
|
+
arguments: { query: " natural ", roast: "light" },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(result.isError, false);
|
|
44
|
+
assert.deepEqual(result.structuredContent, {
|
|
45
|
+
query: "natural",
|
|
46
|
+
roastFilter: "light",
|
|
47
|
+
returnedCount: 1,
|
|
48
|
+
moreMatchesAvailable: true,
|
|
49
|
+
ratingScale: {
|
|
50
|
+
acidity: "1 = low acidity; 5 = high acidity",
|
|
51
|
+
body: "1 = light body; 5 = full body",
|
|
52
|
+
},
|
|
53
|
+
coffees: [
|
|
54
|
+
{
|
|
55
|
+
name: "Riverlight Natural",
|
|
56
|
+
roaster: "North Star Sample Roasters",
|
|
57
|
+
origin: "Burundi",
|
|
58
|
+
roast: "light",
|
|
59
|
+
processingMethod: "Natural",
|
|
60
|
+
flavourNotes: "Blackberry, hibiscus",
|
|
61
|
+
acidityLevel: 5,
|
|
62
|
+
bodyLevel: 2,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
source: "BrewMark",
|
|
66
|
+
sourceUrl: "https://brewmark.io",
|
|
67
|
+
});
|
|
68
|
+
assert.match(result.content[0].text, /Acidity: 1 = low acidity; 5 = high acidity\./);
|
|
69
|
+
assert.match(result.content[0].text, /Body: 1 = light body; 5 = full body\./);
|
|
70
|
+
assert.equal(requests[0].pathname, "/api/coffees");
|
|
71
|
+
assert.deepEqual(requests[0].searchParams, {
|
|
72
|
+
q: "natural",
|
|
73
|
+
roastLevel: "LIGHT",
|
|
74
|
+
sort: "alpha",
|
|
75
|
+
limit: "5",
|
|
76
|
+
});
|
|
77
|
+
assert.equal(requests[0].signal instanceof AbortSignal, true);
|
|
78
|
+
assert.ok(requests[0].deadlineMs > Date.now());
|
|
79
|
+
|
|
80
|
+
response = {
|
|
81
|
+
data: [{
|
|
82
|
+
name: "Details Pending",
|
|
83
|
+
roasterName: "Sample Coffee",
|
|
84
|
+
roastLevel: "LIGHT",
|
|
85
|
+
origin: null,
|
|
86
|
+
processingMethod: null,
|
|
87
|
+
flavorProfile: null,
|
|
88
|
+
acidityLevel: null,
|
|
89
|
+
bodyLevel: null,
|
|
90
|
+
}],
|
|
91
|
+
cursor: null,
|
|
92
|
+
hasMore: false,
|
|
93
|
+
};
|
|
94
|
+
const missingDetails = await client.callTool({
|
|
95
|
+
name: "search-coffee-catalog",
|
|
96
|
+
arguments: { query: "pending" },
|
|
97
|
+
});
|
|
98
|
+
assert.equal(missingDetails.isError, false);
|
|
99
|
+
assert.deepEqual(missingDetails.structuredContent.coffees[0], {
|
|
100
|
+
name: "Details Pending",
|
|
101
|
+
roaster: "Sample Coffee",
|
|
102
|
+
origin: null,
|
|
103
|
+
roast: "light",
|
|
104
|
+
processingMethod: null,
|
|
105
|
+
flavourNotes: null,
|
|
106
|
+
acidityLevel: null,
|
|
107
|
+
bodyLevel: null,
|
|
108
|
+
});
|
|
109
|
+
assert.match(missingDetails.content[0].text, /origin: not provided/);
|
|
110
|
+
assert.match(missingDetails.content[0].text, /acidity: not provided/);
|
|
111
|
+
assert.match(missingDetails.content[0].text, /body: not provided/);
|
|
112
|
+
|
|
113
|
+
response = { data: [{ name: "bad provider row" }], cursor: null, hasMore: false };
|
|
114
|
+
const invalidProviderData = await client.callTool({
|
|
115
|
+
name: "search-coffee-catalog",
|
|
116
|
+
arguments: { query: "natural" },
|
|
117
|
+
});
|
|
118
|
+
assert.equal(invalidProviderData.isError, true);
|
|
119
|
+
assert.doesNotMatch(JSON.stringify(invalidProviderData), /bad provider row|roasterName/);
|
|
120
|
+
|
|
121
|
+
response = new Error("private-provider-error");
|
|
122
|
+
const providerFailure = await client.callTool({
|
|
123
|
+
name: "search-coffee-catalog",
|
|
124
|
+
arguments: { query: "natural" },
|
|
125
|
+
});
|
|
126
|
+
assert.equal(providerFailure.isError, true);
|
|
127
|
+
assert.doesNotMatch(JSON.stringify(providerFailure), /private-provider-error|brewmark\.io/i);
|
|
128
|
+
} finally {
|
|
129
|
+
await client.close();
|
|
130
|
+
await running.close();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const brewmarkFixture = {
|
|
2
|
+
data: [
|
|
3
|
+
{
|
|
4
|
+
name: "Riverlight Natural",
|
|
5
|
+
roasterName: "North Star Sample Roasters",
|
|
6
|
+
roastLevel: "LIGHT",
|
|
7
|
+
origin: "Burundi",
|
|
8
|
+
processingMethod: "Natural",
|
|
9
|
+
flavorProfile: "Blackberry, hibiscus",
|
|
10
|
+
acidityLevel: 5,
|
|
11
|
+
bodyLevel: 2,
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: "Cedar Grove",
|
|
15
|
+
roasterName: "Harbour Sample Coffee",
|
|
16
|
+
roastLevel: "MEDIUM",
|
|
17
|
+
origin: "Colombia",
|
|
18
|
+
processingMethod: "Washed",
|
|
19
|
+
flavorProfile: "Caramel, almond",
|
|
20
|
+
acidityLevel: 2,
|
|
21
|
+
bodyLevel: 4,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
cursor: "fixture-next-page",
|
|
25
|
+
hasMore: true,
|
|
26
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { serveEmseepea } from "@emseepea/server";
|
|
3
|
+
import { createBackendExample } from "../dist/app.js";
|
|
4
|
+
import { brewmarkFixture } from "./brewmark-fixture.mjs";
|
|
5
|
+
|
|
6
|
+
const app = createBackendExample({
|
|
7
|
+
async get({ pathname, searchParams }) {
|
|
8
|
+
assert.equal(pathname, "/api/coffees");
|
|
9
|
+
const { q, ...options } = searchParams;
|
|
10
|
+
assert.match(q, /\bnatural\b/i);
|
|
11
|
+
assert.deepEqual(options, { sort: "alpha", limit: "5" });
|
|
12
|
+
return brewmarkFixture;
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
const running = await serveEmseepea(app, { port: 0 });
|
|
16
|
+
|
|
17
|
+
console.log(`Em See Pea backend no-UI fixture listening at ${running.url}`);
|
|
18
|
+
|
|
19
|
+
async function shutdown() {
|
|
20
|
+
await running.close();
|
|
21
|
+
process.exitCode = 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process.once("SIGINT", () => void shutdown());
|
|
25
|
+
process.once("SIGTERM", () => void shutdown());
|
|
@@ -0,0 +1,18 @@
|
|
|
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": [
|
|
13
|
+
"src/**/*.ts"
|
|
14
|
+
],
|
|
15
|
+
"exclude": [
|
|
16
|
+
"src/**/* 2.ts"
|
|
17
|
+
]
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@emseepea/create-api-backed-server",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Create an Em See Pea server backed by a public web API.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/emseepea/emseepea.git",
|
|
9
|
+
"directory": "packages/create-api-backed-server"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://emseepea.github.io/emseepea/examples/",
|
|
12
|
+
"bugs": "https://github.com/emseepea/emseepea/issues",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public",
|
|
15
|
+
"provenance": false,
|
|
16
|
+
"tag": "next"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"create-api-backed-server": "./dist/create.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
}
|
|
28
|
+
}
|