Skip to content
Merged
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,32 @@ npm run check

| Example | Run | Demonstrates |
| --- | --- | --- |
| [TypeChain tool definition](examples/typechain-tool-definition.ts) | `npm run example:typechain` | `@Tool()` metadata and direct receiver-bound invocation. |
| [TypeChain policy guard](examples/typechain-policy-guard.ts) | `npm run example:policy` | `@Policy()` declaration plus an application-owned approval/audit decision. |
| [TypeMCP server definition](examples/typemcp-server-definition.ts) | `npm run example:typemcp` | `@McpServer()`, `@McpTool()`, metadata inspection, and explicit compilation. |
| [TypeMCP → TypeChain bridge](examples/typemcp-langchain-bridge.ts) | `npm run example:bridge` | Adapt a TypeMCP server into in-process LangChain-compatible tools through TypeChain. |
| [Swagger Petstore TypeMCP wrapper](examples/typemcp-petstore-server.ts) | `npm run example:petstore:live` | Wrap public Swagger Petstore **read-only** operations in `@McpServer()` / `@McpTool()` declarations. |
| [Swagger Petstore TypeChain workflow](examples/typechain-petstore-agent.ts) | `npm run example:petstore:agent` | Adapt the Petstore TypeMCP tool into an in-process TypeChain/LangChain-compatible tool and deterministically summarize live available pets. |
| [TypeChain tool definition](examples/typechain-tool-definition.ts) | `npm run example:typechain` | Foundational `@Tool()` metadata and direct receiver-bound invocation. |
| [TypeChain policy guard](examples/typechain-policy-guard.ts) | `npm run example:policy` | Foundational `@Policy()` declaration plus an application-owned approval/audit decision. |
| [TypeMCP server definition](examples/typemcp-server-definition.ts) | `npm run example:typemcp` | Minimal decorator and explicit compilation reference. |
| [TypeMCP → TypeChain bridge](examples/typemcp-langchain-bridge.ts) | `npm run example:bridge` | Minimal in-process adapter reference. |

## Swagger Petstore scenario

The primary scenario uses the public [Swagger Petstore v2](https://petstore.swagger.io/) demo API at the fixed base URL `https://petstore.swagger.io/v2`.

```bash
# Real public GET calls: available pets, one pet detail, and inventory.
npm run example:petstore:live

# Real TypeChain → adapted TypeMCP tool call followed by a deterministic summary.
npm run example:petstore:agent

# Deterministic fixtures used by unit tests; no network request.
npm run example:petstore:typemcp:fixture
npm run example:petstore:agent:fixture
```

The TypeMCP server exposes exactly three tools: `search_available_pets`, `get_pet`, and `get_petstore_inventory`. The client fixes the public HTTPS base URL, sends only `GET` requests, applies a timeout, validates JSON response shapes, and contains no API-key, credential, create, update, or delete operation.

The public Petstore service is demo infrastructure: records, status counts, and availability can change or be unavailable. Consequently, CI and unit tests use injected fixtures, while the two `:live` commands are intentional manual smoke demonstrations. The TypeChain workflow deterministically selects and calls the adapted `search_available_pets` tool; it is **not** an LLM-driven agent and it does not create an MCP client/session or host an MCP transport.

## Boundaries that the examples intentionally preserve

Expand Down
90 changes: 90 additions & 0 deletions docs/planning/2026-07-29-swagger-petstore-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Swagger Petstore Agent Examples Implementation Plan

> **For Hermes:** Implement this issue-scoped plan with test-first slices and preserve the `dev` → release-only `main` workflow.

**Goal:** Replace the local catalog examples with read-only Swagger Petstore examples that wrap live Petstore GET operations in TypeMCP and show a TypeChain-adapted, deterministic agent-style summary.

**Architecture:** A small injected-fetch `PetstoreClient` owns the fixed public v2 base URL, timeout, GET-only requests, and JSON shape validation. `PetstoreServer` receives that client through TypeMCP’s explicit resolver and exposes `search_available_pets`, `get_pet`, and `get_petstore_inventory`; TypeChain adapts only the search tool into a deterministic summary flow. Unit tests inject fixture fetch responses, while a separate `example:petstore:live` command uses Node’s real `fetch` against the public demo API.

**Tech Stack:** TypeScript strict mode, Node 20+ fetch/AbortSignal, Zod, Vitest, `@theorvane/type-mcp`, TypeChain’s TypeMCP bridge.

---

## Scope and boundaries

- Fixed endpoint: `https://petstore.swagger.io/v2`.
- Allowed operations: `GET /pet/findByStatus?status=available`, `GET /pet/{petId}`, and `GET /store/inventory`.
- The live executable can only read public demo data; it has no write endpoint, API key support, user-supplied base URL, MCP stdio/HTTP hosting, or model-provider integration.
- Deterministic tests must not access the network.
- Live demo output must declare that public demo contents change between invocations.

## Task 1: Add the Petstore client contract and fixture tests

**Files:**
- Create: `examples/petstore-client.ts`
- Create: `test/petstore-client.test.ts`

1. Write failing fixture-fetch tests for available-pet query encoding, `getPet`, inventory normalization, non-OK response error, malformed JSON payload rejection, and timeout/abort behavior.
2. Run `npm test -- --run test/petstore-client.test.ts`; expect missing-module failure.
3. Implement `PetstoreClient` with injected `fetch`, a `readonly` fixed base URL, GET-only request helper, finite positive timeout validation, `AbortSignal.timeout`, response status guard, and Zod schemas.
4. Run the focused test until green; commit the slice.

## Task 2: Add TypeMCP’s Petstore wrapper

**Files:**
- Create: `examples/typemcp-petstore-server.ts`
- Create: `test/typemcp-petstore-server.test.ts`

1. Write failing tests proving declaration metadata contains exactly `search_available_pets`, `get_pet`, and `get_petstore_inventory`, and that injected fixture client calls return normalized Petstore data.
2. Run focused test; expect missing module/decorator implementation failure.
3. Implement `@McpServer({ name: "swagger-petstore", version: "1.0.0" })` and three `@McpTool` methods with explicit Zod inputs. Inject `PetstoreClient` through the constructor and use an explicit resolver for compilation.
4. Verify focused tests and commit the slice.

## Task 3: Add the TypeChain agent-style bridge

**Files:**
- Create: `examples/typechain-petstore-agent.ts`
- Create: `test/typechain-petstore-agent.test.ts`

1. Write a failing deterministic test that creates a server with a fixture client, bridges its TypeMCP search tool with `createTypeMcpLangChainTools`, invokes it, and returns a concise summary containing current available-pet details.
2. Run the focused test and confirm it fails because the agent module does not exist.
3. Implement `summarizeAvailablePets` with an explicit server resolver, tool lookup by its TypeMCP declaration name, input validation delegated to the adapted tool, and a plain deterministic summary—not LLM inference.
4. Run focused and full tests, then commit.

## Task 4: Provide live read-only executable commands

**Files:**
- Create: `examples/petstore-live.ts`
- Modify: `package.json`
- Modify: `test/run-example.ts` only if the current generic runner needs a deterministic fixture mode.

1. Write a failing test for a separate deterministic fixture-mode runner where necessary; do not put live network calls in normal unit tests.
2. Add `example:petstore:live` to call the real client and print a compact JSON payload containing `source`, `readOnly`, selected available pets, one resolved pet detail when an ID exists, and normalized inventory counts.
3. Add `example:petstore:agent` to execute the live TypeChain/TypeMCP path and emit its summary.
4. Run both live commands manually after tests pass. A transient upstream error must be surfaced honestly rather than simulated as success.
5. Commit this slice.

## Task 5: Update developer documentation and validate release quality

**Files:**
- Modify: `README.md`
- Modify: `.github/workflows/verify.yml` only if it needs fixture-only commands added; never add public-network calls to CI.

1. Document the Petstore TypeMCP wrapper, TypeChain bridge, live commands, fixed endpoint, public-demo volatility, read-only policy, and no-model-provider boundary.
2. Preserve existing local catalog examples only if still pedagogically distinct; otherwise remove stale commands/tests/docs in the same change.
3. Run: `npm run format`, `npm run lint`, `npm run build`, `npm test`, `npm run audit:prod`, `npm run check`, `git diff --check`.
4. Run both explicit live commands and capture actual output only in verification notes/PR, never as hardcoded test data.
5. Commit, push, open a `dev` PR, obtain exact-head review/CI, then use the established reviewed `dev → main` promotion path.

## Acceptance cases

| Case | Expected behavior |
| --- | --- |
| Available pet lookup | Client encodes `status=available`; TypeMCP tool returns validated Petstore records. |
| Pet detail | Tool takes a positive numeric ID and returns validated normalized detail. |
| Inventory | Tool returns a validated string-to-nonnegative-number map. |
| Untrusted API response | Invalid JSON shape and non-2xx status fail with descriptive error. |
| Timeout | Slow fetch is aborted; no hidden retry/write action is attempted. |
| Agent flow | TypeChain calls the TypeMCP-adapted search tool and returns a deterministic human-readable live-data summary. |
| Offline CI | All unit tests use fixture fetch; CI performs no Petstore request. |
| Safety | No write method, credential field, configurable base URL, hosted MCP transport, or model-provider behavior is introduced. |
113 changes: 113 additions & 0 deletions examples/petstore-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { z } from "zod";

const PETSTORE_BASE_URL = "https://petstore.swagger.io/v2";

const rawPetSchema = z.object({
id: z.number().int().nonnegative(),
name: z.string(),
photoUrls: z.array(z.string()).optional(),
status: z.string().optional(),
category: z
.object({
name: z.string(),
})
.optional(),
});

const inventorySchema = z.record(z.string(), z.number().finite().nonnegative());

export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;

export type PetstorePet = Readonly<{
id: number;
name: string;
status: string | undefined;
category?: string;
}>;

export type PetstoreClientOptions = Readonly<{
fetch: FetchLike;
timeoutMs: number;
}>;

export class PetstoreClient {
readonly #fetch: FetchLike;
readonly #timeoutMs: number;

public constructor(options: PetstoreClientOptions) {
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error("timeoutMs must be a positive finite number");
}
this.#fetch = options.fetch;
this.#timeoutMs = options.timeoutMs;
}

public async findAvailablePets(): Promise<readonly PetstorePet[]> {
const payload = await this.#getJson("/pet/findByStatus?status=available");
const arrayPayload = z.array(z.unknown()).safeParse(payload);
if (!arrayPayload.success) {
throw new Error(
"Swagger Petstore returned an invalid available-pet payload",
);
}
const pets = arrayPayload.data
.map((candidate) => rawPetSchema.safeParse(candidate))
.flatMap((candidate) =>
candidate.success ? [normalizePet(candidate.data)] : [],
);
if (pets.length === 0) {
throw new Error(
"Swagger Petstore returned no valid available-pet records",
);
}
return pets;
}

public async getPet(petId: number): Promise<PetstorePet> {
if (!Number.isInteger(petId) || petId <= 0) {
throw new Error("petId must be a positive integer");
}
const payload = await this.#getJson(`/pet/${petId}`);
const parsed = rawPetSchema.safeParse(payload);
if (!parsed.success) {
throw new Error("Swagger Petstore returned an invalid pet payload");
}
return normalizePet(parsed.data);
}

public async getInventory(): Promise<Readonly<Record<string, number>>> {
const payload = await this.#getJson("/store/inventory");
const parsed = inventorySchema.safeParse(payload);
if (!parsed.success) {
throw new Error("Swagger Petstore returned an invalid inventory payload");
}
return parsed.data;
}

async #getJson(path: string): Promise<unknown> {
const response = await this.#fetch(`${PETSTORE_BASE_URL}${path}`, {
method: "GET",
headers: { accept: "application/json" },
signal: AbortSignal.timeout(this.#timeoutMs),
});
if (!response.ok) {
throw new Error(
`Swagger Petstore GET ${path.split("?")[0]} failed with HTTP ${response.status}`,
);
}
return response.json();
}
}

function normalizePet(pet: z.infer<typeof rawPetSchema>): PetstorePet {
return {
id: pet.id,
name: pet.name,
status: pet.status,
...(pet.category === undefined ? {} : { category: pet.category.name }),
};
}

export function createLivePetstoreClient(timeoutMs = 10_000): PetstoreClient {
return new PetstoreClient({ fetch: globalThis.fetch, timeoutMs });
}
37 changes: 37 additions & 0 deletions examples/petstore-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type FetchLike, PetstoreClient } from "./petstore-client.js";
import { PetstoreServer } from "./typemcp-petstore-server.js";

function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}

export function createFixturePetstoreServer(): PetstoreServer {
let calls = 0;
const fetch: FetchLike = async () => {
calls += 1;
switch (calls) {
case 1:
return jsonResponse([
{ id: 1, name: "Milo", photoUrls: [], status: "available" },
{ id: 2, name: "Nori", photoUrls: [], status: "available" },
]);
case 2:
return jsonResponse({
id: 2,
name: "Nori",
photoUrls: [],
status: "pending",
});
case 3:
return jsonResponse({ available: 2, sold: 1 });
default:
throw new Error(`Unexpected fixture request ${calls}`);
}
};
return PetstoreServer.withClient(
new PetstoreClient({ fetch, timeoutMs: 1_000 }),
);
}
30 changes: 30 additions & 0 deletions examples/petstore-live.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { PetstoreServer } from "./typemcp-petstore-server.js";

export async function run(): Promise<void> {
const server = new PetstoreServer();
const available = await server.searchAvailablePets({ limit: 3 });
const firstPet = available[0];
const pet = firstPet
? await server.getPet({ petId: firstPet.id })
: undefined;
const inventory = await server.getPetstoreInventory({});

console.log(
JSON.stringify(
{
source: "https://petstore.swagger.io/v2",
readOnly: true,
available,
pet,
inventory,
note: "Swagger Petstore is a public demo API; live data can change between runs.",
},
null,
2,
),
);
}

if (import.meta.url === `file://${process.argv[1]}`) {
await run();
}
14 changes: 14 additions & 0 deletions examples/typechain-petstore-agent-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createFixturePetstoreServer } from "./petstore-fixture.js";
import { summarizeAvailablePets } from "./typechain-petstore-agent.js";

export async function run(): Promise<void> {
const summary = await summarizeAvailablePets(
createFixturePetstoreServer(),
2,
);
console.log(JSON.stringify(summary, null, 2));
}

if (import.meta.url === `file://${process.argv[1]}`) {
await run();
}
22 changes: 22 additions & 0 deletions examples/typechain-petstore-agent-live.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { summarizeAvailablePets } from "./typechain-petstore-agent.js";
import { PetstoreServer } from "./typemcp-petstore-server.js";

export async function run(): Promise<void> {
const result = await summarizeAvailablePets(new PetstoreServer(), 3);
console.log(
JSON.stringify(
{
source: "https://petstore.swagger.io/v2",
readOnly: true,
...result,
note: "This deterministic workflow invokes a TypeMCP-derived TypeChain tool; it does not configure an LLM or MCP transport.",
},
null,
2,
),
);
}

if (import.meta.url === `file://${process.argv[1]}`) {
await run();
}
Loading