@emseepea/create-api-backed-server 0.0.3 → 0.0.5
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 +26 -17
- package/initializer-dist/template/README.md +16 -16
- package/initializer-dist/template/eval/meaning.test.mjs +15 -24
- package/initializer-dist/template/package.json +2 -2
- package/initializer-dist/template/src/app.ts +2 -2
- package/initializer-dist/template/src/capabilities/tool.search-pea-taxa.ts +88 -0
- package/initializer-dist/template/src/server.ts +2 -2
- package/initializer-dist/template/test/server.test.mjs +43 -76
- package/initializer-dist/template/test-support/inaturalist-fixture.mjs +19 -0
- package/initializer-dist/template/test-support/llm-server.mjs +6 -6
- package/package.json +5 -5
- package/initializer-dist/template/src/capabilities/tool.search-coffee-catalog.ts +0 -103
- package/initializer-dist/template/test-support/brewmark-fixture.mjs +0 -26
package/README.md
CHANGED
|
@@ -2,8 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
This directory is both the maintained example and its public npm initializer.
|
|
4
4
|
|
|
5
|
+
## Use This Template
|
|
6
|
+
|
|
7
|
+
Use this template when a tool calls a public JSON API and must validate and
|
|
8
|
+
select the fields returned to the caller. Choose the [tool server](../tool-server/README.md)
|
|
9
|
+
when all data is local, or the [sign-in tool server](../sign-in-tool-server/README.md)
|
|
10
|
+
when calling the tool requires authentication. [Compare all eight templates](https://emseepea.github.io/emseepea/examples/).
|
|
11
|
+
|
|
12
|
+
## Create a Project
|
|
13
|
+
|
|
5
14
|
```sh
|
|
6
|
-
npm init @emseepea/api-backed-server
|
|
15
|
+
npm init @emseepea/api-backed-server -- my-server
|
|
7
16
|
```
|
|
8
17
|
|
|
9
18
|
<!-- generated-project-readme -->
|
|
@@ -13,28 +22,28 @@ npm init @emseepea/api-backed-server@next -- my-server
|
|
|
13
22
|
Choose this example when your MCP tool needs to read a public web service and
|
|
14
23
|
return selected, checked data in a clear result for an assistant.
|
|
15
24
|
|
|
16
|
-
This example exposes one read-only `search-
|
|
17
|
-
|
|
18
|
-
and returns at most five
|
|
25
|
+
This example exposes one read-only `search-pea-taxa` tool. A normal run searches
|
|
26
|
+
[iNaturalist's public taxon catalogue](https://api.inaturalist.org/v1/docs/)
|
|
27
|
+
and returns at most five matching species.
|
|
19
28
|
|
|
20
|
-
Unlike the [first public tool example](https://github.com/emseepea/emseepea/tree/main/examples/
|
|
29
|
+
Unlike the [first public tool example](https://github.com/emseepea/emseepea/tree/main/examples/tool-server),
|
|
21
30
|
this tool adapts a separate service. The public input and result use the Model Context Protocol
|
|
22
|
-
(MCP).
|
|
31
|
+
(MCP). The iNaturalist query and response are checked before the result is returned.
|
|
23
32
|
|
|
24
|
-
The tool preserves
|
|
25
|
-
|
|
26
|
-
an MCP release. Separate public and backend schemas still select and
|
|
27
|
-
field that can reach the caller.
|
|
33
|
+
The tool preserves iNaturalist's useful field names and values. For example,
|
|
34
|
+
`rank` remains an open string, so iNaturalist can add a valid rank without
|
|
35
|
+
requiring an MCP release. Separate public and backend schemas still select and
|
|
36
|
+
check every field that can reach the caller.
|
|
28
37
|
|
|
29
|
-
The caller can choose a search term
|
|
30
|
-
the website, path, result limit,
|
|
31
|
-
terms are sent to
|
|
38
|
+
The caller can choose a search term. The caller cannot change
|
|
39
|
+
the website, path, result limit, credentials, or HTTP rules. Search
|
|
40
|
+
terms are sent to iNaturalist. The example does not send authentication details or
|
|
32
41
|
change data. Do not include personal, secret, or confidential information in a
|
|
33
42
|
search term.
|
|
34
43
|
|
|
35
|
-
The automated checks use
|
|
36
|
-
They do not depend on
|
|
37
|
-
claim for
|
|
44
|
+
The automated checks use fixed pea taxon records through the same MCP server.
|
|
45
|
+
They do not depend on iNaturalist being available and do not make a speed or uptime
|
|
46
|
+
claim for iNaturalist. Normal runs use iNaturalist's public API.
|
|
38
47
|
|
|
39
48
|
## Run
|
|
40
49
|
|
|
@@ -60,7 +69,7 @@ Run its build, mapping, validation, and MCP checks:
|
|
|
60
69
|
npm test
|
|
61
70
|
```
|
|
62
71
|
|
|
63
|
-
Check that Claude chooses
|
|
72
|
+
Check that Claude chooses taxon search and understands the observation count:
|
|
64
73
|
|
|
65
74
|
```sh
|
|
66
75
|
npm run test:llm
|
|
@@ -3,28 +3,28 @@
|
|
|
3
3
|
Choose this example when your MCP tool needs to read a public web service and
|
|
4
4
|
return selected, checked data in a clear result for an assistant.
|
|
5
5
|
|
|
6
|
-
This example exposes one read-only `search-
|
|
7
|
-
|
|
8
|
-
and returns at most five
|
|
6
|
+
This example exposes one read-only `search-pea-taxa` tool. A normal run searches
|
|
7
|
+
[iNaturalist's public taxon catalogue](https://api.inaturalist.org/v1/docs/)
|
|
8
|
+
and returns at most five matching species.
|
|
9
9
|
|
|
10
|
-
Unlike the [first public tool example](https://github.com/emseepea/emseepea/tree/main/examples/
|
|
10
|
+
Unlike the [first public tool example](https://github.com/emseepea/emseepea/tree/main/examples/tool-server),
|
|
11
11
|
this tool adapts a separate service. The public input and result use the Model Context Protocol
|
|
12
|
-
(MCP).
|
|
12
|
+
(MCP). The iNaturalist query and response are checked before the result is returned.
|
|
13
13
|
|
|
14
|
-
The tool preserves
|
|
15
|
-
|
|
16
|
-
an MCP release. Separate public and backend schemas still select and
|
|
17
|
-
field that can reach the caller.
|
|
14
|
+
The tool preserves iNaturalist's useful field names and values. For example,
|
|
15
|
+
`rank` remains an open string, so iNaturalist can add a valid rank without
|
|
16
|
+
requiring an MCP release. Separate public and backend schemas still select and
|
|
17
|
+
check every field that can reach the caller.
|
|
18
18
|
|
|
19
|
-
The caller can choose a search term
|
|
20
|
-
the website, path, result limit,
|
|
21
|
-
terms are sent to
|
|
19
|
+
The caller can choose a search term. The caller cannot change
|
|
20
|
+
the website, path, result limit, credentials, or HTTP rules. Search
|
|
21
|
+
terms are sent to iNaturalist. The example does not send authentication details or
|
|
22
22
|
change data. Do not include personal, secret, or confidential information in a
|
|
23
23
|
search term.
|
|
24
24
|
|
|
25
|
-
The automated checks use
|
|
26
|
-
They do not depend on
|
|
27
|
-
claim for
|
|
25
|
+
The automated checks use fixed pea taxon records through the same MCP server.
|
|
26
|
+
They do not depend on iNaturalist being available and do not make a speed or uptime
|
|
27
|
+
claim for iNaturalist. Normal runs use iNaturalist's public API.
|
|
28
28
|
|
|
29
29
|
## Run
|
|
30
30
|
|
|
@@ -50,7 +50,7 @@ Run its build, mapping, validation, and MCP checks:
|
|
|
50
50
|
npm test
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Check that Claude chooses
|
|
53
|
+
Check that Claude chooses taxon search and understands the observation count:
|
|
54
54
|
|
|
55
55
|
```sh
|
|
56
56
|
npm run test:llm
|
|
@@ -1,32 +1,23 @@
|
|
|
1
1
|
import { toolSelectionTest } from "@emseepea/testing/semantic";
|
|
2
2
|
|
|
3
|
-
toolSelectionTest("
|
|
3
|
+
toolSelectionTest("Taxon fields retain their documented meaning", {
|
|
4
4
|
server: new URL("../test-support/llm-server.mjs", import.meta.url),
|
|
5
5
|
question:
|
|
6
|
-
"Search the
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"and which has the fuller body?",
|
|
6
|
+
"Search the public taxon catalogue for pea species. Give the scientific and " +
|
|
7
|
+
"common names of each result, say which has more recorded observations, and " +
|
|
8
|
+
"explain whether that count estimates the wild population.",
|
|
10
9
|
criticalFacts: [
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"more matches",
|
|
18
|
-
"low acidity",
|
|
19
|
-
"high acidity",
|
|
20
|
-
"light body",
|
|
21
|
-
"full body"
|
|
10
|
+
"Pisum sativum",
|
|
11
|
+
"Common Pea",
|
|
12
|
+
"Lathyrus odoratus",
|
|
13
|
+
"Sweet Pea",
|
|
14
|
+
/8,?720/,
|
|
15
|
+
/6,?240/
|
|
22
16
|
],
|
|
23
17
|
criteria:
|
|
24
|
-
"The answer reports
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
"fuller body. It does not treat either score as a quality rating or reverse " +
|
|
30
|
-
"either scale.",
|
|
31
|
-
expectedTools: ["search-coffee-catalog"],
|
|
18
|
+
"The answer reports Pisum sativum as Common Pea and Lathyrus odoratus as Sweet " +
|
|
19
|
+
"Pea. It says Pisum sativum has more recorded observations, 8720 compared with " +
|
|
20
|
+
"6240. It explains that observations_count is a count of recorded observations, " +
|
|
21
|
+
"not an estimate of the wild population.",
|
|
22
|
+
expectedTools: ["search-pea-taxa"],
|
|
32
23
|
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"lint": "oxlint src test eval test-support"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@emseepea/testing": "0.2.
|
|
17
|
+
"@emseepea/testing": "0.2.2",
|
|
18
18
|
"@modelcontextprotocol/client": "2.0.0",
|
|
19
19
|
"@types/node": "24.13.3",
|
|
20
20
|
"typescript": "6.0.3",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"private": true,
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@emseepea/server": "0.
|
|
28
|
+
"@emseepea/server": "0.3.0",
|
|
29
29
|
"zod": "4.4.3"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -3,9 +3,9 @@ import type { JsonHttpClient } from "@emseepea/server/http";
|
|
|
3
3
|
|
|
4
4
|
export async function createBackendExample(client: JsonHttpClient): Promise<ReturnType<typeof createEmseepea>> {
|
|
5
5
|
return createEmseepea({
|
|
6
|
-
name: "emseepea-
|
|
6
|
+
name: "emseepea-api-backed-server",
|
|
7
7
|
version: "0.0.0",
|
|
8
|
-
instructions: "Use search-
|
|
8
|
+
instructions: "Use search-pea-taxa to search iNaturalist's public taxon catalogue for pea species.",
|
|
9
9
|
...await discoverCapabilities(new URL("./capabilities/", import.meta.url), { client }),
|
|
10
10
|
});
|
|
11
11
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { defineMappedTool, type CapabilityModuleFactory } from "@emseepea/server";
|
|
2
|
+
import type { JsonHttpClient } from "@emseepea/server/http";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export interface BackendExampleContext { readonly client: JsonHttpClient }
|
|
6
|
+
|
|
7
|
+
const taxon = z.object({
|
|
8
|
+
id: z.number().int().positive(),
|
|
9
|
+
name: z.string().min(1).max(200),
|
|
10
|
+
preferred_common_name: z.string().min(1).max(200).nullable().optional(),
|
|
11
|
+
rank: z.string().min(1).max(40),
|
|
12
|
+
observations_count: z.number().int().nonnegative(),
|
|
13
|
+
});
|
|
14
|
+
const backendTaxon = z.object({
|
|
15
|
+
id: z.number().int().positive(),
|
|
16
|
+
name: z.string().min(1).max(200),
|
|
17
|
+
preferred_common_name: z.string().min(1).max(200).nullable().optional(),
|
|
18
|
+
rank: z.string().min(1).max(40),
|
|
19
|
+
observations_count: z.number().int().nonnegative(),
|
|
20
|
+
});
|
|
21
|
+
const searchInput = z.object({ query: z.string().trim().min(2).max(80) });
|
|
22
|
+
const searchReport = z.object({
|
|
23
|
+
query: z.string().max(80),
|
|
24
|
+
total_results: z.number().int().nonnegative(),
|
|
25
|
+
results: z.array(taxon).max(5),
|
|
26
|
+
source: z.literal("iNaturalist"),
|
|
27
|
+
source_url: z.literal("https://www.inaturalist.org"),
|
|
28
|
+
});
|
|
29
|
+
const backendCommand = z.object({
|
|
30
|
+
pathname: z.literal("/v1/taxa"),
|
|
31
|
+
searchParams: z.object({
|
|
32
|
+
q: z.string().min(2).max(80),
|
|
33
|
+
rank: z.literal("species"),
|
|
34
|
+
per_page: z.literal("5"),
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
const backendPayload = z.object({
|
|
38
|
+
total_results: z.number().int().nonnegative(),
|
|
39
|
+
results: z.array(backendTaxon).max(5),
|
|
40
|
+
});
|
|
41
|
+
const backendResult = z.object({ request: backendCommand, payload: backendPayload });
|
|
42
|
+
|
|
43
|
+
export default (({ client }) => defineMappedTool({
|
|
44
|
+
name: "search-pea-taxa",
|
|
45
|
+
access: "public",
|
|
46
|
+
description: "Search iNaturalist's public taxon catalogue for pea species.",
|
|
47
|
+
inputSchema: searchInput,
|
|
48
|
+
outputSchema: searchReport,
|
|
49
|
+
backendInputSchema: backendCommand,
|
|
50
|
+
backendOutputSchema: backendResult,
|
|
51
|
+
mapInput: ({ query }) => ({
|
|
52
|
+
pathname: "/v1/taxa" as const,
|
|
53
|
+
searchParams: { q: query, rank: "species" as const, per_page: "5" as const },
|
|
54
|
+
}),
|
|
55
|
+
async adapter(request, { signal, deadlineMs }) {
|
|
56
|
+
return { request, payload: await client.get({ ...request, signal, deadlineMs }) };
|
|
57
|
+
},
|
|
58
|
+
mapOutput: ({ request, payload }) => {
|
|
59
|
+
const data = {
|
|
60
|
+
query: request.searchParams.q,
|
|
61
|
+
total_results: payload.total_results,
|
|
62
|
+
results: payload.results.map((record) => ({
|
|
63
|
+
id: record.id,
|
|
64
|
+
name: record.name,
|
|
65
|
+
preferred_common_name: record.preferred_common_name,
|
|
66
|
+
rank: record.rank,
|
|
67
|
+
observations_count: record.observations_count,
|
|
68
|
+
})),
|
|
69
|
+
source: "iNaturalist" as const,
|
|
70
|
+
source_url: "https://www.inaturalist.org" as const,
|
|
71
|
+
};
|
|
72
|
+
const lines = data.results.map((record) => [
|
|
73
|
+
record.preferred_common_name ?? "Common name not provided",
|
|
74
|
+
record.name,
|
|
75
|
+
`rank: ${record.rank}`,
|
|
76
|
+
`recorded observations: ${record.observations_count}`,
|
|
77
|
+
].join("; "));
|
|
78
|
+
return {
|
|
79
|
+
text: [
|
|
80
|
+
`iNaturalist returned ${data.results.length} of ${data.total_results} matching taxa for “${data.query}”.`,
|
|
81
|
+
"observations_count is the number of recorded observations, not a population estimate.",
|
|
82
|
+
...lines,
|
|
83
|
+
"Source: https://www.inaturalist.org",
|
|
84
|
+
].join("\n"),
|
|
85
|
+
data,
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
})) satisfies CapabilityModuleFactory<BackendExampleContext>;
|
|
@@ -3,7 +3,7 @@ import { createJsonHttpClient } from "@emseepea/server/http";
|
|
|
3
3
|
import { createBackendExample } from "./app.js";
|
|
4
4
|
|
|
5
5
|
const client = createJsonHttpClient({
|
|
6
|
-
origin: "https://
|
|
6
|
+
origin: "https://api.inaturalist.org",
|
|
7
7
|
maxResponseBytes: 128 * 1024,
|
|
8
8
|
});
|
|
9
9
|
const running = await serveEmseepea(
|
|
@@ -11,7 +11,7 @@ const running = await serveEmseepea(
|
|
|
11
11
|
{ port: Number.parseInt(process.env.PORT ?? "3000", 10) },
|
|
12
12
|
);
|
|
13
13
|
|
|
14
|
-
console.log(`Em See Pea
|
|
14
|
+
console.log(`Em See Pea API-backed server example listening at ${running.url}`);
|
|
15
15
|
|
|
16
16
|
async function shutdown(): Promise<void> {
|
|
17
17
|
await running.close();
|
|
@@ -3,130 +3,97 @@ import test from "node:test";
|
|
|
3
3
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
4
4
|
import { serveEmseepea } from "@emseepea/server";
|
|
5
5
|
import { createBackendExample } from "../dist/app.js";
|
|
6
|
-
import {
|
|
6
|
+
import { inaturalistFixture } from "../test-support/inaturalist-fixture.mjs";
|
|
7
7
|
|
|
8
|
-
test("the
|
|
8
|
+
test("the API-backed example checks and passes through selected iNaturalist values", async () => {
|
|
9
9
|
const requests = [];
|
|
10
|
-
let response =
|
|
10
|
+
let response = inaturalistFixture;
|
|
11
11
|
const app = await createBackendExample({
|
|
12
12
|
async get(options) {
|
|
13
13
|
requests.push(options);
|
|
14
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
15
|
return response;
|
|
19
16
|
},
|
|
20
17
|
});
|
|
21
18
|
const running = await serveEmseepea(app, { port: 0 });
|
|
22
19
|
const client = new Client(
|
|
23
|
-
{ name: "
|
|
20
|
+
{ name: "api-backed-example-test", version: "0.0.0" },
|
|
24
21
|
{ versionNegotiation: { mode: { pin: "2026-07-28" } } },
|
|
25
22
|
);
|
|
26
23
|
await client.connect(new StreamableHTTPClientTransport(new URL(running.url)));
|
|
27
24
|
|
|
28
25
|
try {
|
|
29
26
|
const listed = await client.listTools();
|
|
30
|
-
assert.deepEqual(listed.tools.map(({ name }) => name), ["search-
|
|
27
|
+
assert.deepEqual(listed.tools.map(({ name }) => name), ["search-pea-taxa"]);
|
|
31
28
|
|
|
32
29
|
const invalidInput = await client.callTool({
|
|
33
|
-
name: "search-
|
|
30
|
+
name: "search-pea-taxa",
|
|
34
31
|
arguments: { query: "x" },
|
|
35
32
|
});
|
|
36
33
|
assert.equal(invalidInput.isError, true);
|
|
37
34
|
assert.equal(requests.length, 0);
|
|
38
35
|
|
|
39
36
|
const result = await client.callTool({
|
|
40
|
-
name: "search-
|
|
41
|
-
arguments: { query: "
|
|
37
|
+
name: "search-pea-taxa",
|
|
38
|
+
arguments: { query: " pea " },
|
|
42
39
|
});
|
|
43
40
|
assert.equal(result.isError, false);
|
|
44
41
|
assert.deepEqual(result.structuredContent, {
|
|
45
|
-
query: "
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
roasterName: "North Star Sample Roasters",
|
|
57
|
-
roastLevel: "LIGHT",
|
|
58
|
-
origin: "Burundi",
|
|
59
|
-
processingMethod: "Natural",
|
|
60
|
-
flavorProfile: "Blackberry, hibiscus",
|
|
61
|
-
acidityLevel: 5,
|
|
62
|
-
bodyLevel: 2,
|
|
63
|
-
},
|
|
64
|
-
],
|
|
65
|
-
source: "BrewMark",
|
|
66
|
-
sourceUrl: "https://brewmark.io",
|
|
42
|
+
query: "pea",
|
|
43
|
+
...inaturalistFixture,
|
|
44
|
+
source: "iNaturalist",
|
|
45
|
+
source_url: "https://www.inaturalist.org",
|
|
67
46
|
});
|
|
68
|
-
assert.match(result.content[0].text, /
|
|
69
|
-
assert.
|
|
70
|
-
assert.equal(requests[0].pathname, "/api/coffees");
|
|
47
|
+
assert.match(result.content[0].text, /not a population estimate/);
|
|
48
|
+
assert.equal(requests[0].pathname, "/v1/taxa");
|
|
71
49
|
assert.deepEqual(requests[0].searchParams, {
|
|
72
|
-
q: "
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
limit: "5",
|
|
50
|
+
q: "pea",
|
|
51
|
+
rank: "species",
|
|
52
|
+
per_page: "5",
|
|
76
53
|
});
|
|
77
54
|
assert.equal(requests[0].signal instanceof AbortSignal, true);
|
|
78
55
|
assert.ok(requests[0].deadlineMs > Date.now());
|
|
79
56
|
|
|
80
57
|
response = {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
bodyLevel: null,
|
|
90
|
-
privateNote: "do not expose",
|
|
58
|
+
total_results: 1,
|
|
59
|
+
results: [{
|
|
60
|
+
id: 123,
|
|
61
|
+
name: "Pisum example",
|
|
62
|
+
preferred_common_name: null,
|
|
63
|
+
rank: "new-provider-rank",
|
|
64
|
+
observations_count: 0,
|
|
65
|
+
private_note: "do not expose",
|
|
91
66
|
}],
|
|
92
|
-
cursor: null,
|
|
93
|
-
hasMore: false,
|
|
94
67
|
};
|
|
95
|
-
const
|
|
96
|
-
name: "search-
|
|
97
|
-
arguments: { query: "
|
|
68
|
+
const newProviderValue = await client.callTool({
|
|
69
|
+
name: "search-pea-taxa",
|
|
70
|
+
arguments: { query: "Pisum" },
|
|
98
71
|
});
|
|
99
|
-
assert.equal(
|
|
100
|
-
assert.deepEqual(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
flavorProfile: null,
|
|
107
|
-
acidityLevel: null,
|
|
108
|
-
bodyLevel: null,
|
|
72
|
+
assert.equal(newProviderValue.isError, false);
|
|
73
|
+
assert.deepEqual(newProviderValue.structuredContent.results[0], {
|
|
74
|
+
id: 123,
|
|
75
|
+
name: "Pisum example",
|
|
76
|
+
preferred_common_name: null,
|
|
77
|
+
rank: "new-provider-rank",
|
|
78
|
+
observations_count: 0,
|
|
109
79
|
});
|
|
110
|
-
assert.equal("
|
|
111
|
-
assert.match(missingDetails.content[0].text, /origin: not provided/);
|
|
112
|
-
assert.match(missingDetails.content[0].text, /acidity: not provided/);
|
|
113
|
-
assert.match(missingDetails.content[0].text, /body: not provided/);
|
|
80
|
+
assert.equal("private_note" in newProviderValue.structuredContent.results[0], false);
|
|
114
81
|
|
|
115
|
-
response = {
|
|
82
|
+
response = { total_results: 1, results: [{ id: "bad provider row" }] };
|
|
116
83
|
const invalidProviderData = await client.callTool({
|
|
117
|
-
name: "search-
|
|
118
|
-
arguments: { query: "
|
|
84
|
+
name: "search-pea-taxa",
|
|
85
|
+
arguments: { query: "pea" },
|
|
119
86
|
});
|
|
120
87
|
assert.equal(invalidProviderData.isError, true);
|
|
121
|
-
assert.doesNotMatch(JSON.stringify(invalidProviderData), /bad provider row|
|
|
88
|
+
assert.doesNotMatch(JSON.stringify(invalidProviderData), /bad provider row|preferred_common_name/);
|
|
122
89
|
|
|
123
90
|
response = new Error("private-provider-error");
|
|
124
91
|
const providerFailure = await client.callTool({
|
|
125
|
-
name: "search-
|
|
126
|
-
arguments: { query: "
|
|
92
|
+
name: "search-pea-taxa",
|
|
93
|
+
arguments: { query: "pea" },
|
|
127
94
|
});
|
|
128
95
|
assert.equal(providerFailure.isError, true);
|
|
129
|
-
assert.doesNotMatch(JSON.stringify(providerFailure), /private-provider-error|
|
|
96
|
+
assert.doesNotMatch(JSON.stringify(providerFailure), /private-provider-error|inaturalist\.org/i);
|
|
130
97
|
} finally {
|
|
131
98
|
await client.close();
|
|
132
99
|
await running.close();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const inaturalistFixture = {
|
|
2
|
+
total_results: 2,
|
|
3
|
+
results: [
|
|
4
|
+
{
|
|
5
|
+
id: 54522,
|
|
6
|
+
name: "Pisum sativum",
|
|
7
|
+
preferred_common_name: "Common Pea",
|
|
8
|
+
rank: "species",
|
|
9
|
+
observations_count: 8720,
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: 125973,
|
|
13
|
+
name: "Lathyrus odoratus",
|
|
14
|
+
preferred_common_name: "Sweet Pea",
|
|
15
|
+
rank: "species",
|
|
16
|
+
observations_count: 6240,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { serveEmseepea } from "@emseepea/server";
|
|
3
3
|
import { createBackendExample } from "../dist/app.js";
|
|
4
|
-
import {
|
|
4
|
+
import { inaturalistFixture } from "./inaturalist-fixture.mjs";
|
|
5
5
|
|
|
6
6
|
const app = await createBackendExample({
|
|
7
7
|
async get({ pathname, searchParams }) {
|
|
8
|
-
assert.equal(pathname, "/
|
|
8
|
+
assert.equal(pathname, "/v1/taxa");
|
|
9
9
|
const { q, ...options } = searchParams;
|
|
10
|
-
assert.match(q, /\
|
|
11
|
-
assert.deepEqual(options, {
|
|
12
|
-
return
|
|
10
|
+
assert.match(q, /\bpea\b/i);
|
|
11
|
+
assert.deepEqual(options, { rank: "species", per_page: "5" });
|
|
12
|
+
return inaturalistFixture;
|
|
13
13
|
},
|
|
14
14
|
});
|
|
15
15
|
const running = await serveEmseepea(app, { port: 0 });
|
|
16
16
|
|
|
17
|
-
console.log(`Em See Pea
|
|
17
|
+
console.log(`Em See Pea API-backed fixture listening at ${running.url}`);
|
|
18
18
|
|
|
19
19
|
async function shutdown() {
|
|
20
20
|
await running.close();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/create-api-backed-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Create an Em See Pea server backed by a public web API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"prepack": "npm run build:initializer"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@emseepea/server": "0.
|
|
21
|
+
"@emseepea/server": "0.3.0",
|
|
22
22
|
"zod": "4.4.3",
|
|
23
|
-
"@emseepea/testing": "0.2.
|
|
23
|
+
"@emseepea/testing": "0.2.2",
|
|
24
24
|
"@modelcontextprotocol/client": "2.0.0",
|
|
25
25
|
"@types/node": "24.13.3",
|
|
26
26
|
"typescript": "6.0.3",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"engines": {
|
|
30
30
|
"node": ">=22"
|
|
31
31
|
},
|
|
32
|
-
"repository": { "type": "git", "url": "git+https://github.com/emseepea/emseepea.git", "directory": "examples/
|
|
32
|
+
"repository": { "type": "git", "url": "git+https://github.com/emseepea/emseepea.git", "directory": "examples/api-backed-server" },
|
|
33
33
|
"homepage": "https://emseepea.github.io/emseepea/examples/",
|
|
34
34
|
"bugs": "https://github.com/emseepea/emseepea/issues",
|
|
35
|
-
"publishConfig": { "access": "public", "provenance": true
|
|
35
|
+
"publishConfig": { "access": "public", "provenance": true },
|
|
36
36
|
"bin": { "create-api-backed-server": "./initializer-dist/create.mjs" },
|
|
37
37
|
"files": ["initializer-dist"]
|
|
38
38
|
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { defineMappedTool, type CapabilityModuleFactory } from "@emseepea/server";
|
|
2
|
-
import type { JsonHttpClient } from "@emseepea/server/http";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
|
|
5
|
-
export interface BackendExampleContext { readonly client: JsonHttpClient }
|
|
6
|
-
|
|
7
|
-
const roastLevel = z.string().trim().min(1).max(40);
|
|
8
|
-
const searchInput = z.object({
|
|
9
|
-
query: z.string().trim().min(2).max(80),
|
|
10
|
-
roastLevel: roastLevel.describe("Optional BrewMark roast level, such as LIGHT or MEDIUM_LIGHT.").optional(),
|
|
11
|
-
});
|
|
12
|
-
const coffee = z.object({
|
|
13
|
-
name: z.string().max(200), roasterName: z.string().max(200), roastLevel,
|
|
14
|
-
origin: z.string().max(200).nullable(), processingMethod: z.string().max(100).nullable(),
|
|
15
|
-
flavorProfile: z.string().max(500).nullable(), acidityLevel: z.number().int().min(1).max(5).nullable(),
|
|
16
|
-
bodyLevel: z.number().int().min(1).max(5).nullable(),
|
|
17
|
-
});
|
|
18
|
-
const searchReport = z.object({
|
|
19
|
-
query: z.string().max(80), roastLevelFilter: roastLevel.nullable(), returnedCount: z.number().int().min(0).max(5),
|
|
20
|
-
moreMatchesAvailable: z.boolean(),
|
|
21
|
-
ratingScale: z.object({
|
|
22
|
-
acidity: z.literal("1 = low acidity; 5 = high acidity"),
|
|
23
|
-
body: z.literal("1 = light body; 5 = full body"),
|
|
24
|
-
}),
|
|
25
|
-
coffees: z.array(coffee).max(5), source: z.literal("BrewMark"), sourceUrl: z.literal("https://brewmark.io"),
|
|
26
|
-
});
|
|
27
|
-
const backendCommand = z.object({
|
|
28
|
-
pathname: z.literal("/api/coffees"),
|
|
29
|
-
searchParams: z.object({
|
|
30
|
-
q: z.string().min(2).max(80), roastLevel: roastLevel.optional(),
|
|
31
|
-
sort: z.literal("alpha"), limit: z.literal("5"),
|
|
32
|
-
}),
|
|
33
|
-
});
|
|
34
|
-
const backendCoffee = z.object({
|
|
35
|
-
name: z.string().max(200), roasterName: z.string().max(200), roastLevel,
|
|
36
|
-
origin: z.string().max(200).nullable(), processingMethod: z.string().max(100).nullable(),
|
|
37
|
-
flavorProfile: z.string().max(500).nullable(), acidityLevel: z.number().int().min(1).max(5).nullable(),
|
|
38
|
-
bodyLevel: z.number().int().min(1).max(5).nullable(),
|
|
39
|
-
});
|
|
40
|
-
const backendPayload = z.object({
|
|
41
|
-
data: z.array(backendCoffee).max(5), cursor: z.string().max(2_048).nullable(), hasMore: z.boolean(),
|
|
42
|
-
});
|
|
43
|
-
const backendResult = z.object({ request: backendCommand, payload: backendPayload });
|
|
44
|
-
|
|
45
|
-
export default (({ client }) => defineMappedTool({
|
|
46
|
-
name: "search-coffee-catalog",
|
|
47
|
-
access: "public",
|
|
48
|
-
description: "Search BrewMark's public coffee catalogue and explain its acidity and body ratings.",
|
|
49
|
-
inputSchema: searchInput,
|
|
50
|
-
outputSchema: searchReport,
|
|
51
|
-
backendInputSchema: backendCommand,
|
|
52
|
-
backendOutputSchema: backendResult,
|
|
53
|
-
mapInput: ({ query, roastLevel }) => ({
|
|
54
|
-
pathname: "/api/coffees" as const,
|
|
55
|
-
searchParams: {
|
|
56
|
-
q: query,
|
|
57
|
-
...(roastLevel ? { roastLevel } : {}),
|
|
58
|
-
sort: "alpha" as const,
|
|
59
|
-
limit: "5" as const,
|
|
60
|
-
},
|
|
61
|
-
}),
|
|
62
|
-
async adapter(request, { signal, deadlineMs }) {
|
|
63
|
-
return { request, payload: await client.get({ ...request, signal, deadlineMs }) };
|
|
64
|
-
},
|
|
65
|
-
mapOutput: ({ request, payload }) => {
|
|
66
|
-
const coffees = payload.data.map((record) => ({
|
|
67
|
-
name: record.name, roasterName: record.roasterName, roastLevel: record.roastLevel,
|
|
68
|
-
origin: record.origin, processingMethod: record.processingMethod,
|
|
69
|
-
flavorProfile: record.flavorProfile, acidityLevel: record.acidityLevel, bodyLevel: record.bodyLevel,
|
|
70
|
-
}));
|
|
71
|
-
const data = {
|
|
72
|
-
query: request.searchParams.q,
|
|
73
|
-
roastLevelFilter: request.searchParams.roastLevel ?? null,
|
|
74
|
-
returnedCount: coffees.length,
|
|
75
|
-
moreMatchesAvailable: payload.hasMore,
|
|
76
|
-
ratingScale: {
|
|
77
|
-
acidity: "1 = low acidity; 5 = high acidity" as const,
|
|
78
|
-
body: "1 = light body; 5 = full body" as const,
|
|
79
|
-
},
|
|
80
|
-
coffees,
|
|
81
|
-
source: "BrewMark" as const,
|
|
82
|
-
sourceUrl: "https://brewmark.io" as const,
|
|
83
|
-
};
|
|
84
|
-
const lines = coffees.map((record) => [
|
|
85
|
-
`${record.name} by ${record.roasterName}`,
|
|
86
|
-
`origin: ${record.origin ?? "not provided"}`,
|
|
87
|
-
`roast level: ${record.roastLevel}`,
|
|
88
|
-
`acidity: ${record.acidityLevel ?? "not provided"}`,
|
|
89
|
-
`body: ${record.bodyLevel ?? "not provided"}`,
|
|
90
|
-
].join("; "));
|
|
91
|
-
return {
|
|
92
|
-
text: [
|
|
93
|
-
`BrewMark returned ${coffees.length} coffee${coffees.length === 1 ? "" : "s"} for “${data.query}”.`,
|
|
94
|
-
`More matches available: ${data.moreMatchesAvailable ? "yes" : "no"}.`,
|
|
95
|
-
"Acidity: 1 = low acidity; 5 = high acidity.",
|
|
96
|
-
"Body: 1 = light body; 5 = full body.",
|
|
97
|
-
...lines,
|
|
98
|
-
"Source: https://brewmark.io",
|
|
99
|
-
].join("\n"),
|
|
100
|
-
data,
|
|
101
|
-
};
|
|
102
|
-
},
|
|
103
|
-
})) satisfies CapabilityModuleFactory<BackendExampleContext>;
|
|
@@ -1,26 +0,0 @@
|
|
|
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
|
-
};
|