@emseepea/create-api-backed-server 0.0.2 → 0.0.4

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 CHANGED
@@ -1,22 +1,78 @@
1
1
  # `@emseepea/create-api-backed-server`
2
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).
3
+ This directory is both the maintained example and its public npm initializer.
6
4
 
7
- ## Create the Project
5
+ ## Use This Template
8
6
 
9
- This initializer is queued for the next pre-alpha release and is not yet
10
- available from npm.
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
11
13
 
12
14
  ```sh
13
- npm init @emseepea/api-backed-server@next -- my-server
15
+ npm init @emseepea/api-backed-server -- my-server
14
16
  ```
15
17
 
18
+ <!-- generated-project-readme -->
19
+
20
+ ## Public Web Service Backend Example
21
+
22
+ Choose this example when your MCP tool needs to read a public web service and
23
+ return selected, checked data in a clear result for an assistant.
24
+
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.
28
+
29
+ Unlike the [first public tool example](https://github.com/emseepea/emseepea/tree/main/examples/tool-server),
30
+ this tool adapts a separate service. The public input and result use the Model Context Protocol
31
+ (MCP). The iNaturalist query and response are checked before the result is returned.
32
+
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.
37
+
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
41
+ change data. Do not include personal, secret, or confidential information in a
42
+ search term.
43
+
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.
47
+
48
+ ## Run
49
+
50
+ From this directory:
51
+
16
52
  ```sh
17
- cd my-server
18
53
  npm install
19
- npm test
20
- npm run lint
54
+ npm run build
21
55
  npm start
22
56
  ```
57
+
58
+ The endpoint is `http://127.0.0.1:3000/mcp`.
59
+
60
+ ## Check This Example
61
+
62
+ [Ordinary tests](test/) live in `test/`.
63
+ The [AI tool-choice and understanding test](eval/meaning.test.mjs) lives separately in `eval/`.
64
+ The commands below run each suite independently.
65
+
66
+ Run its build, mapping, validation, and MCP checks:
67
+
68
+ ```sh
69
+ npm test
70
+ ```
71
+
72
+ Check that Claude chooses taxon search and understands the observation count:
73
+
74
+ ```sh
75
+ npm run test:llm
76
+ ```
77
+
78
+ If Claude is not already signed in, run `claude auth login` first.
@@ -0,0 +1,59 @@
1
+ # Public Web Service Backend Example
2
+
3
+ Choose this example when your MCP tool needs to read a public web service and
4
+ return selected, checked data in a clear result for an assistant.
5
+
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
+
10
+ Unlike the [first public tool example](https://github.com/emseepea/emseepea/tree/main/examples/tool-server),
11
+ this tool adapts a separate service. The public input and result use the Model Context Protocol
12
+ (MCP). The iNaturalist query and response are checked before the result is returned.
13
+
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
+
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
+ change data. Do not include personal, secret, or confidential information in a
23
+ search term.
24
+
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
+
29
+ ## Run
30
+
31
+ From this directory:
32
+
33
+ ```sh
34
+ npm install
35
+ npm run build
36
+ npm start
37
+ ```
38
+
39
+ The endpoint is `http://127.0.0.1:3000/mcp`.
40
+
41
+ ## Check This Example
42
+
43
+ [Ordinary tests](test/) live in `test/`.
44
+ The [AI tool-choice and understanding test](eval/meaning.test.mjs) lives separately in `eval/`.
45
+ The commands below run each suite independently.
46
+
47
+ Run its build, mapping, validation, and MCP checks:
48
+
49
+ ```sh
50
+ npm test
51
+ ```
52
+
53
+ Check that Claude chooses taxon search and understands the observation count:
54
+
55
+ ```sh
56
+ npm run test:llm
57
+ ```
58
+
59
+ If Claude is not already signed in, run `claude auth login` first.
@@ -0,0 +1,23 @@
1
+ import { toolSelectionTest } from "@emseepea/testing/semantic";
2
+
3
+ toolSelectionTest("Taxon fields retain their documented meaning", {
4
+ server: new URL("../test-support/llm-server.mjs", import.meta.url),
5
+ question:
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.",
9
+ criticalFacts: [
10
+ "Pisum sativum",
11
+ "Common Pea",
12
+ "Lathyrus odoratus",
13
+ "Sweet Pea",
14
+ /8,?720/,
15
+ /6,?240/
16
+ ],
17
+ criteria:
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"],
23
+ });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "emseepea-starter",
3
3
  "version": "0.0.0",
4
- "private": true,
4
+ "description": "Create an Em See Pea server backed by a public web API.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "scripts": {
@@ -13,12 +13,8 @@
13
13
  "test:llm:built": "emseepea-test eval",
14
14
  "lint": "oxlint src test eval test-support"
15
15
  },
16
- "dependencies": {
17
- "@emseepea/server": "0.1.0",
18
- "zod": "4.4.3"
19
- },
20
16
  "devDependencies": {
21
- "@emseepea/testing": "0.2.1",
17
+ "@emseepea/testing": "0.2.2",
22
18
  "@modelcontextprotocol/client": "2.0.0",
23
19
  "@types/node": "24.13.3",
24
20
  "typescript": "6.0.3",
@@ -26,5 +22,10 @@
26
22
  },
27
23
  "engines": {
28
24
  "node": ">=22"
25
+ },
26
+ "private": true,
27
+ "dependencies": {
28
+ "@emseepea/server": "0.2.2",
29
+ "zod": "4.4.3"
29
30
  }
30
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-backend-no-ui",
6
+ name: "emseepea-api-backed-server",
7
7
  version: "0.0.0",
8
- instructions: "Use search-coffee-catalog to search BrewMark's public coffee catalogue.",
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://brewmark.io",
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 backend no-UI example listening at ${running.url}`);
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();
@@ -0,0 +1,101 @@
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 { inaturalistFixture } from "../test-support/inaturalist-fixture.mjs";
7
+
8
+ test("the API-backed example checks and passes through selected iNaturalist values", async () => {
9
+ const requests = [];
10
+ let response = inaturalistFixture;
11
+ const app = await createBackendExample({
12
+ async get(options) {
13
+ requests.push(options);
14
+ if (response instanceof Error) throw response;
15
+ return response;
16
+ },
17
+ });
18
+ const running = await serveEmseepea(app, { port: 0 });
19
+ const client = new Client(
20
+ { name: "api-backed-example-test", version: "0.0.0" },
21
+ { versionNegotiation: { mode: { pin: "2026-07-28" } } },
22
+ );
23
+ await client.connect(new StreamableHTTPClientTransport(new URL(running.url)));
24
+
25
+ try {
26
+ const listed = await client.listTools();
27
+ assert.deepEqual(listed.tools.map(({ name }) => name), ["search-pea-taxa"]);
28
+
29
+ const invalidInput = await client.callTool({
30
+ name: "search-pea-taxa",
31
+ arguments: { query: "x" },
32
+ });
33
+ assert.equal(invalidInput.isError, true);
34
+ assert.equal(requests.length, 0);
35
+
36
+ const result = await client.callTool({
37
+ name: "search-pea-taxa",
38
+ arguments: { query: " pea " },
39
+ });
40
+ assert.equal(result.isError, false);
41
+ assert.deepEqual(result.structuredContent, {
42
+ query: "pea",
43
+ ...inaturalistFixture,
44
+ source: "iNaturalist",
45
+ source_url: "https://www.inaturalist.org",
46
+ });
47
+ assert.match(result.content[0].text, /not a population estimate/);
48
+ assert.equal(requests[0].pathname, "/v1/taxa");
49
+ assert.deepEqual(requests[0].searchParams, {
50
+ q: "pea",
51
+ rank: "species",
52
+ per_page: "5",
53
+ });
54
+ assert.equal(requests[0].signal instanceof AbortSignal, true);
55
+ assert.ok(requests[0].deadlineMs > Date.now());
56
+
57
+ response = {
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",
66
+ }],
67
+ };
68
+ const newProviderValue = await client.callTool({
69
+ name: "search-pea-taxa",
70
+ arguments: { query: "Pisum" },
71
+ });
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,
79
+ });
80
+ assert.equal("private_note" in newProviderValue.structuredContent.results[0], false);
81
+
82
+ response = { total_results: 1, results: [{ id: "bad provider row" }] };
83
+ const invalidProviderData = await client.callTool({
84
+ name: "search-pea-taxa",
85
+ arguments: { query: "pea" },
86
+ });
87
+ assert.equal(invalidProviderData.isError, true);
88
+ assert.doesNotMatch(JSON.stringify(invalidProviderData), /bad provider row|preferred_common_name/);
89
+
90
+ response = new Error("private-provider-error");
91
+ const providerFailure = await client.callTool({
92
+ name: "search-pea-taxa",
93
+ arguments: { query: "pea" },
94
+ });
95
+ assert.equal(providerFailure.isError, true);
96
+ assert.doesNotMatch(JSON.stringify(providerFailure), /private-provider-error|inaturalist\.org/i);
97
+ } finally {
98
+ await client.close();
99
+ await running.close();
100
+ }
101
+ });
@@ -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 { brewmarkFixture } from "./brewmark-fixture.mjs";
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, "/api/coffees");
8
+ assert.equal(pathname, "/v1/taxa");
9
9
  const { q, ...options } = searchParams;
10
- assert.match(q, /\bnatural\b/i);
11
- assert.deepEqual(options, { sort: "alpha", limit: "5" });
12
- return brewmarkFixture;
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 backend no-UI fixture listening at ${running.url}`);
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,22 +1,38 @@
1
1
  {
2
2
  "name": "@emseepea/create-api-backed-server",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Create an Em See Pea server backed by a public web API.",
5
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": { "access": "public", "provenance": true, "tag": "next" },
14
6
  "type": "module",
15
- "bin": { "create-api-backed-server": "./dist/create.mjs" },
16
- "files": ["dist"],
7
+ "starterDependencies": ["@emseepea/server", "zod"],
17
8
  "scripts": {
18
- "build": "node ../../scripts/build-initializer.mjs",
19
- "prepack": "npm run build"
9
+ "build": "npm run build:example && npm run build:initializer",
10
+ "build:example": "tsc -p tsconfig.json",
11
+ "build:initializer": "node ../../scripts/build-initializer.mjs",
12
+ "start": "node dist/server.js",
13
+ "test": "npm run build && npm run test:built",
14
+ "test:built": "node --test test/*.test.mjs",
15
+ "test:llm": "npm run build && npm run test:llm:built",
16
+ "test:llm:built": "emseepea-test eval",
17
+ "lint": "oxlint src test eval test-support",
18
+ "prepack": "npm run build:initializer"
19
+ },
20
+ "devDependencies": {
21
+ "@emseepea/server": "0.2.2",
22
+ "zod": "4.4.3",
23
+ "@emseepea/testing": "0.2.2",
24
+ "@modelcontextprotocol/client": "2.0.0",
25
+ "@types/node": "24.13.3",
26
+ "typescript": "6.0.3",
27
+ "oxlint": "1.80.0"
20
28
  },
21
- "engines": { "node": ">=22" }
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "repository": { "type": "git", "url": "git+https://github.com/emseepea/emseepea.git", "directory": "examples/api-backed-server" },
33
+ "homepage": "https://emseepea.github.io/emseepea/examples/",
34
+ "bugs": "https://github.com/emseepea/emseepea/issues",
35
+ "publishConfig": { "access": "public", "provenance": true },
36
+ "bin": { "create-api-backed-server": "./initializer-dist/create.mjs" },
37
+ "files": ["initializer-dist"]
22
38
  }
@@ -1,54 +0,0 @@
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.
@@ -1,32 +0,0 @@
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
- });
@@ -1,112 +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 publicRoast = z.enum(["light", "medium-light", "medium", "medium-dark", "dark"]);
8
- const brewmarkRoast = z.enum(["LIGHT", "MEDIUM_LIGHT", "MEDIUM", "MEDIUM_DARK", "DARK"]);
9
- const publicToBrewmarkRoast = {
10
- light: "LIGHT", "medium-light": "MEDIUM_LIGHT", medium: "MEDIUM",
11
- "medium-dark": "MEDIUM_DARK", dark: "DARK",
12
- } as const;
13
- const brewmarkToPublicRoast = {
14
- LIGHT: "light", MEDIUM_LIGHT: "medium-light", MEDIUM: "medium",
15
- MEDIUM_DARK: "medium-dark", DARK: "dark",
16
- } as const;
17
- const searchInput = z.object({ query: z.string().trim().min(2).max(80), roast: publicRoast.optional() });
18
- const coffee = z.object({
19
- name: z.string().max(200), roaster: z.string().max(200), origin: z.string().max(200).nullable(),
20
- roast: publicRoast, processingMethod: z.string().max(100).nullable(),
21
- flavourNotes: z.string().max(500).nullable(), acidityLevel: z.number().int().min(1).max(5).nullable(),
22
- bodyLevel: z.number().int().min(1).max(5).nullable(),
23
- });
24
- const searchReport = z.object({
25
- query: z.string().max(80), roastFilter: publicRoast.nullable(), returnedCount: z.number().int().min(0).max(5),
26
- moreMatchesAvailable: z.boolean(),
27
- ratingScale: z.object({
28
- acidity: z.literal("1 = low acidity; 5 = high acidity"),
29
- body: z.literal("1 = light body; 5 = full body"),
30
- }),
31
- coffees: z.array(coffee).max(5), source: z.literal("BrewMark"), sourceUrl: z.literal("https://brewmark.io"),
32
- });
33
- const backendCommand = z.object({
34
- pathname: z.literal("/api/coffees"),
35
- searchParams: z.object({
36
- q: z.string().min(2).max(80), roastLevel: brewmarkRoast.optional(),
37
- sort: z.literal("alpha"), limit: z.literal("5"),
38
- }),
39
- });
40
- const backendCoffee = z.object({
41
- name: z.string().max(200), roasterName: z.string().max(200), roastLevel: brewmarkRoast,
42
- origin: z.string().max(200).nullable(), processingMethod: z.string().max(100).nullable(),
43
- flavorProfile: z.string().max(500).nullable(), acidityLevel: z.number().int().min(1).max(5).nullable(),
44
- bodyLevel: z.number().int().min(1).max(5).nullable(),
45
- });
46
- const backendPayload = z.object({
47
- data: z.array(backendCoffee).max(5), cursor: z.string().max(2_048).nullable(), hasMore: z.boolean(),
48
- });
49
- const backendResult = z.object({ request: backendCommand, payload: backendPayload });
50
-
51
- export default (({ client }) => defineMappedTool({
52
- name: "search-coffee-catalog",
53
- access: "public",
54
- description: "Search BrewMark's public coffee catalogue and explain its acidity and body ratings.",
55
- inputSchema: searchInput,
56
- outputSchema: searchReport,
57
- backendInputSchema: backendCommand,
58
- backendOutputSchema: backendResult,
59
- mapInput: ({ query, roast }) => ({
60
- pathname: "/api/coffees" as const,
61
- searchParams: {
62
- q: query,
63
- ...(roast ? { roastLevel: publicToBrewmarkRoast[roast] } : {}),
64
- sort: "alpha" as const,
65
- limit: "5" as const,
66
- },
67
- }),
68
- async adapter(request, { signal, deadlineMs }) {
69
- return { request, payload: await client.get({ ...request, signal, deadlineMs }) };
70
- },
71
- mapOutput: ({ request, payload }) => {
72
- const coffees = payload.data.map((record) => ({
73
- name: record.name, roaster: record.roasterName, origin: record.origin,
74
- roast: brewmarkToPublicRoast[record.roastLevel], processingMethod: record.processingMethod,
75
- flavourNotes: record.flavorProfile, acidityLevel: record.acidityLevel, bodyLevel: record.bodyLevel,
76
- }));
77
- const roastFilter = request.searchParams.roastLevel
78
- ? brewmarkToPublicRoast[request.searchParams.roastLevel]
79
- : null;
80
- const data = {
81
- query: request.searchParams.q,
82
- roastFilter,
83
- returnedCount: coffees.length,
84
- moreMatchesAvailable: payload.hasMore,
85
- ratingScale: {
86
- acidity: "1 = low acidity; 5 = high acidity" as const,
87
- body: "1 = light body; 5 = full body" as const,
88
- },
89
- coffees,
90
- source: "BrewMark" as const,
91
- sourceUrl: "https://brewmark.io" as const,
92
- };
93
- const lines = coffees.map((record) => [
94
- `${record.name} by ${record.roaster}`,
95
- `origin: ${record.origin ?? "not provided"}`,
96
- `roast: ${record.roast}`,
97
- `acidity: ${record.acidityLevel ?? "not provided"}`,
98
- `body: ${record.bodyLevel ?? "not provided"}`,
99
- ].join("; "));
100
- return {
101
- text: [
102
- `BrewMark returned ${coffees.length} coffee${coffees.length === 1 ? "" : "s"} for “${data.query}”.`,
103
- `More matches available: ${data.moreMatchesAvailable ? "yes" : "no"}.`,
104
- "Acidity: 1 = low acidity; 5 = high acidity.",
105
- "Body: 1 = light body; 5 = full body.",
106
- ...lines,
107
- "Source: https://brewmark.io",
108
- ].join("\n"),
109
- data,
110
- };
111
- },
112
- })) satisfies CapabilityModuleFactory<BackendExampleContext>;
@@ -1,132 +0,0 @@
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 = await 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
- });
@@ -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
- };
File without changes
File without changes