@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 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@next -- my-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-coffee-catalog` tool. A normal run
17
- searches [BrewMark's public coffee catalogue](https://brewmark.io/developers/api-docs)
18
- and returns at most five coffees.
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/basic-no-ui),
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). BrewMark's query and response are checked before the result is returned.
31
+ (MCP). The iNaturalist query and response are checked before the result is returned.
23
32
 
24
- The tool preserves BrewMark's useful field names and values. It does not keep a
25
- second list of roast levels, so BrewMark can add a valid level without requiring
26
- an MCP release. Separate public and backend schemas still select and check every
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 and roast filter. The caller cannot change
30
- the website, path, result limit, sort order, credentials, or HTTP rules. Search
31
- terms are sent to BrewMark. The example does not send authentication details or
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 invented coffee records through the same MCP server.
36
- They do not depend on BrewMark being available and do not make a speed or uptime
37
- claim for BrewMark. Normal runs use BrewMark's fair-use public web service.
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 catalogue search and understands the rating scales:
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-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.
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/basic-no-ui),
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). BrewMark's query and response are checked before the result is returned.
12
+ (MCP). The iNaturalist query and response are checked before the result is returned.
13
13
 
14
- The tool preserves BrewMark's useful field names and values. It does not keep a
15
- second list of roast levels, so BrewMark can add a valid level without requiring
16
- an MCP release. Separate public and backend schemas still select and check every
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 and roast filter. The caller cannot change
20
- the website, path, result limit, sort order, credentials, or HTTP rules. Search
21
- terms are sent to BrewMark. The example does not send authentication details or
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 invented coffee records through the same MCP server.
26
- They do not depend on BrewMark being available and do not make a speed or uptime
27
- claim for BrewMark. Normal runs use BrewMark's fair-use public web service.
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 catalogue search and understands the rating scales:
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("Coffee ratings retain their documented meaning", {
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 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?",
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
- "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"
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 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"],
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.1",
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.2.1",
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-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();
@@ -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 { brewmarkFixture } from "../test-support/brewmark-fixture.mjs";
6
+ import { inaturalistFixture } from "../test-support/inaturalist-fixture.mjs";
7
7
 
8
- test("the backend example checks and passes through selected BrewMark values", async () => {
8
+ test("the API-backed example checks and passes through selected iNaturalist values", async () => {
9
9
  const requests = [];
10
- let response = brewmarkFixture;
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: "backend-example-test", version: "0.0.0" },
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-coffee-catalog"]);
27
+ assert.deepEqual(listed.tools.map(({ name }) => name), ["search-pea-taxa"]);
31
28
 
32
29
  const invalidInput = await client.callTool({
33
- name: "search-coffee-catalog",
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-coffee-catalog",
41
- arguments: { query: " natural ", roastLevel: "LIGHT" },
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: "natural",
46
- roastLevelFilter: "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
- 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, /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");
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: "natural",
73
- roastLevel: "LIGHT",
74
- sort: "alpha",
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
- data: [{
82
- name: "Details Pending",
83
- roasterName: "Sample Coffee",
84
- roastLevel: "OMNI_ROAST",
85
- origin: null,
86
- processingMethod: null,
87
- flavorProfile: null,
88
- acidityLevel: null,
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 missingDetails = await client.callTool({
96
- name: "search-coffee-catalog",
97
- arguments: { query: "pending" },
68
+ const newProviderValue = await client.callTool({
69
+ name: "search-pea-taxa",
70
+ arguments: { query: "Pisum" },
98
71
  });
99
- assert.equal(missingDetails.isError, false);
100
- assert.deepEqual(missingDetails.structuredContent.coffees[0], {
101
- name: "Details Pending",
102
- roasterName: "Sample Coffee",
103
- roastLevel: "OMNI_ROAST",
104
- origin: null,
105
- processingMethod: null,
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("privateNote" in missingDetails.structuredContent.coffees[0], false);
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 = { data: [{ name: "bad provider row" }], cursor: null, hasMore: false };
82
+ response = { total_results: 1, results: [{ id: "bad provider row" }] };
116
83
  const invalidProviderData = await client.callTool({
117
- name: "search-coffee-catalog",
118
- arguments: { query: "natural" },
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|roasterName/);
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-coffee-catalog",
126
- arguments: { query: "natural" },
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|brewmark\.io/i);
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 { 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/create-api-backed-server",
3
- "version": "0.0.3",
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.2.1",
21
+ "@emseepea/server": "0.3.0",
22
22
  "zod": "4.4.3",
23
- "@emseepea/testing": "0.2.1",
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/backend-no-ui" },
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, "tag": "next" },
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
- };