Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1292177
test: extract shared request-capturing loopback helper for unit and e…
frontegg-david Aug 13, 2026
bc41fa8
test: add e2e jest harness, dev-only sdk and ajv deps, and packaging …
frontegg-david Aug 13, 2026
4e9a847
fix: make toJsonSchema cycle-safe and count path-level parameters in …
frontegg-david Aug 13, 2026
9aaf31d
test: vendor petstore, github, and discord spec fixtures with provenance
frontegg-david Aug 13, 2026
b70ecf8
fix: omit non-object output schemas from SDK configs and keep flatten…
frontegg-david Aug 13, 2026
bfadbaf
test: add mcp server and wire serialization e2e stories
frontegg-david Aug 13, 2026
fb1f7eb
test: add real-spec validation, type-signature, arazzo, and curation …
frontegg-david Aug 13, 2026
9c6fa2f
ci: run the e2e suite after build in the push workflow
frontegg-david Aug 13, 2026
6c719c0
docs: scope the inline-spec rule to unit tests and document e2e conve…
frontegg-david Aug 13, 2026
477e048
Merge branch 'main' into test/e2e-stories
frontegg-david Aug 13, 2026
c7c9b78
fix: address review findings on path-level parameter validation, exec…
frontegg-david Aug 13, 2026
df53999
fix: skip reference-object parameters in structural validation and co…
frontegg-david Aug 13, 2026
e860bb8
test: add tested examples harness with the quickstart mcp server example
frontegg-david Aug 13, 2026
9ea2ae4
test: add http request pipeline and secure loading examples
frontegg-david Aug 13, 2026
28935d0
test: add curation, client-targets, and typed-tools examples
frontegg-david Aug 13, 2026
e79c69b
test: add arazzo workflow example and wire examples into the docs
frontegg-david Aug 13, 2026
4438d31
fix: address review findings on pointer decoding, cookie credentials,…
frontegg-david Aug 13, 2026
4a4f480
fix: resolve local parameter refs in coverage checks and harden examp…
frontegg-david Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
- name: Test with coverage
run: yarn test:coverage --coverageReporters=text --coverageReporters=json-summary

- name: E2E stories (uses dist/ from the Build step)
run: yarn test:e2e

- name: Coverage summary
if: always()
run: |
Expand Down
12 changes: 8 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ OpenAPIToolGenerator (src/generator.ts)
yarn test # Run all tests (unit + integration)
yarn test:unit # Run unit tests only
yarn test:integration # Run integration tests only
yarn test:e2e # E2E story suite (requires `yarn build` first — the packaging story consumes dist/)
yarn test:coverage # Run tests with coverage report
yarn build # Build CJS + ESM + type declarations
yarn build:cjs # Build CommonJS output only
Expand All @@ -81,16 +82,19 @@ yarn clean # Remove dist/ and coverage/

- **Framework**: Jest 29 with SWC transformer (`@swc/jest`)
- **Coverage provider**: V8 (`coverageProvider: 'v8'` in jest.config.js)
- **Coverage target**: 100% statements, branches, functions, lines
- **Coverage target**: 100% statements, branches, functions, lines — a UNIT-suite contract; the e2e suite never runs under `test:coverage`
- **Unit tests**: `src/__tests__/*.spec.ts` (one per module)
- **Integration tests**: `src/__tests__/integration.spec.ts` (full pipeline, imports from entrypoint only)
- **E2E stories**: `e2e/*.e2e.ts` under `jest.e2e.config.js` (the `.e2e.ts` suffix + separate `roots` keep the suites structurally separate). Real loopback HTTP, the real MCP SDK over `InMemoryTransport`, vendored real-world specs, dist packaging, and tsc-compiled emitted declarations. `@modelcontextprotocol/sdk` and `ajv` are dev-only and must never be imported from `src/`.
- **Tested examples**: `examples/<name>/` pairs consumer-style code (`example.ts`, importing `mcp-from-openapi` — the e2e runner maps the bare specifier onto `src/index.ts`) with a colocated `example.e2e.ts` executed by `yarn test:e2e`. Each folder has a README; the index at `examples/README.md` and `docs/examples.md` point to them. New examples must follow this shape — an untested example is a doc bug.
- **Coverage exclusion**: `src/index.ts` (barrel file)

### Testing Patterns

- **Inline specs**: Tests create OpenAPI spec objects directly (no fixture files)
- **Real loopback servers**: URL-loading and SSRF/connection-pinning tests drive a real
`127.0.0.1` HTTP server (via the shared `createLoopbackServer` helper) with
- **Inline specs (unit tests)**: Unit tests create OpenAPI spec objects directly (no fixture files). E2E story tests MAY load vendored real-world fixtures from `e2e/fixtures/` — provenance, pinned commits, and licenses documented in `e2e/fixtures/README.md`; regenerating a fixture requires updating the literal count assertions that pin it
- **Real loopback servers**: URL-loading, SSRF/connection-pinning, and e2e wire tests drive a real
`127.0.0.1` HTTP server (via the shared `src/__tests__/helpers/loopback.ts` helper, which also
captures each request's method/url/headers/raw body for wire assertions) with
`refResolution.allowInternalIPs`, exercising the actual Node pinned transport + SSRF guard.
This replaces `global.fetch` mocks and `$RefParser.dereference` spies for those paths, because
the pinned transport bypasses `global.fetch`. Use `jest.spyOn(Response.prototype, …)` for
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Useful commands:
| `yarn test` | Run all tests (unit + integration) |
| `yarn test:unit` | Unit tests only |
| `yarn test:integration` | Integration tests only |
| `yarn test:e2e` | E2E story suite (run `yarn build` first) |
| `yarn test:coverage` | Tests with the enforced coverage gate |
| `yarn build` | Build CJS + ESM + type declarations |

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ for (const tool of await generator.generateTools({ target: "claude" })) {
| [Type Signatures](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/type-signatures.md) | TypeScript call contracts for code-execution surfaces |
| [Modern MCP Fields](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/modern-mcp-fields.md) | Tool `_meta`, icons, `x-mcp-header`, elicitation descriptors |
| [Arazzo Workflows](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/arazzo.md) | fromArazzo() — Arazzo 1.0 workflows as consolidated MCP tools |
| [Tested Examples](https://github.com/agentfront/mcp-from-openapi/tree/main/examples) | Runnable examples, each executed as an e2e test on every CI run |
| [Response Schemas](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/response-schemas.md) | Output schemas, status codes, oneOf unions |
| [Annotations & Extensions](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/annotations.md) | Tool title, annotation inference, `x-mcp` extension family |
| [Security](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/security.md) | SecurityResolver, all auth types, custom resolvers |
Expand Down
2 changes: 2 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

---

> **Runnable, tested examples live in [`examples/`](https://github.com/agentfront/mcp-from-openapi/tree/main/examples)** — each folder pairs consumer-style code with an e2e test that executes it against a real server on every CI run, so they cannot rot: [quickstart MCP server](https://github.com/agentfront/mcp-from-openapi/tree/main/examples/quickstart-mcp-server), [HTTP requests](https://github.com/agentfront/mcp-from-openapi/tree/main/examples/http-requests), [secure loading](https://github.com/agentfront/mcp-from-openapi/tree/main/examples/secure-loading), [curation](https://github.com/agentfront/mcp-from-openapi/tree/main/examples/curation), [client targets](https://github.com/agentfront/mcp-from-openapi/tree/main/examples/client-targets), [typed tools](https://github.com/agentfront/mcp-from-openapi/tree/main/examples/typed-tools), [Arazzo workflows](https://github.com/agentfront/mcp-from-openapi/tree/main/examples/arazzo-workflow). The snippets below are quick-reference excerpts.

## Building an MCP Server

> **Prefer `buildHttpRequest`** from the library for real integrations — see [Request Builder](./request-builder.md). This example shows the underlying mapper contract.
Expand Down
100 changes: 100 additions & 0 deletions e2e/arazzo-executor.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Arazzo story: a workflow IR produced by fromArazzo() is genuinely
* executable — a minimal in-test executor drives it over real loopback HTTP,
* proving `$inputs`, `$steps.*.outputs.*`, and `$response.body#/pointers`
* evaluate all the way onto the wire.
*/
import { fromArazzo } from '../src';
import type { ArazzoDocument } from '../src';
import { createLoopbackServer, type LoopbackHandler } from '../src/__tests__/helpers/loopback';
import { loadFixture } from './helpers/fixtures';
import { executeWorkflow } from './helpers/arazzo-executor';
import * as yaml from 'yaml';

const arazzoDoc: ArazzoDocument = {
arazzo: '1.0.0',
info: { title: 'Order flows', version: '1.0.0' },
sourceDescriptions: [{ name: 'petstore', url: 'https://example.com/petstore.yaml' }],
workflows: [
{
workflowId: 'orderAndFetch',
summary: 'Place an order, then fetch it back',
inputs: {
type: 'object',
properties: { petId: { type: 'integer' }, quantity: { type: 'integer' } },
required: ['petId', 'quantity'],
},
steps: [
{
stepId: 'place',
operationId: 'placeOrder',
requestBody: {
contentType: 'application/json',
payload: { petId: '$inputs.petId', quantity: '$inputs.quantity', status: 'placed' },
},
outputs: { orderId: '$response.body#/id' },
},
{
stepId: 'fetch',
operationId: 'getOrderById',
parameters: [{ name: 'orderId', in: 'path', value: '$steps.place.outputs.orderId' }],
outputs: { order: '$response.body', code: '$statusCode' },
},
],
outputs: { order: '$steps.fetch.outputs.order', orderId: '$steps.place.outputs.orderId' },
},
],
};

describe('story: Arazzo workflow IR drives real HTTP', () => {
const orders = new Map<number, Record<string, unknown>>();
let nextId = 7;
const handler: LoopbackHandler = (req, res, body) => {
if (req.method === 'POST' && req.url === '/store/order') {
const order = { id: nextId++, ...JSON.parse(body.toString()), complete: false };
orders.set(order.id as number, order);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(order));
return;
}
const match = req.url?.match(/^\/store\/order\/(\d+)$/);
if (req.method === 'GET' && match) {
const order = orders.get(Number(match[1]));
res.writeHead(order ? 200 : 404, { 'content-type': 'application/json' });
res.end(JSON.stringify(order ?? { message: 'not found' }));
return;
}
res.writeHead(500);
res.end();
};
const loopback = createLoopbackServer(() => handler);

afterAll(() => loopback.close());

it('realizes workflow outputs end-to-end', async () => {
const baseUrl = await loopback.listen();
const petstore = yaml.parse(loadFixture('petstore-3.0.yaml'));
const [workflowTool] = await fromArazzo(arazzoDoc, { sources: { petstore } });

expect(workflowTool.name).toBe('orderAndFetch');
expect(workflowTool.metadata.workflow!.steps).toHaveLength(2);

const run = await executeWorkflow(workflowTool, { petId: 42, quantity: 2 }, baseUrl);

// step 1: the payload with $inputs substituted reached the wire
const posted = loopback.requests[0];
expect(posted.method).toBe('POST');
expect(posted.url).toBe('/store/order');
expect(JSON.parse(posted.body.toString())).toEqual({ petId: 42, quantity: 2, status: 'placed' });

// step 2: $response.body#/id from step 1 became the path parameter
const fetched = loopback.requests[1];
expect(fetched.method).toBe('GET');
expect(fetched.url).toBe('/store/order/7');

expect(run.steps['place'].outputs['orderId']).toBe(7);
expect(run.steps['fetch'].outputs['code']).toBe(200);
expect(run.outputs['orderId']).toBe(7);
expect(run.outputs['order']).toEqual({ id: 7, petId: 42, quantity: 2, status: 'placed', complete: false });
});
});
77 changes: 77 additions & 0 deletions e2e/curation-journey.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Curation story on the big GitHub fixture: measure the context bill,
* patch the spec with an overlay (without forking it), trim aggressively,
* and filter — asserting the budget actually shrinks and every schema stays
* valid along the way.
*/
import { OpenAPIToolGenerator, analyzeToolSet, lintDocument } from '../src';
import type { OpenAPIDocument } from '../src';
import { loadJsonFixture } from './helpers/fixtures';
import { compileAll } from './helpers/ajv';

describe('story: curation journey over the GitHub fixture', () => {
const document = () => loadJsonFixture<OpenAPIDocument>('github-trimmed-3.0.json');

it('measures, patches, trims, and filters the tool set', async () => {
// 1. Baseline: the raw context bill
const baseline = await (await OpenAPIToolGenerator.fromJSON(document())).generateTools();
const baselineTokens = analyzeToolSet(baseline).estimatedTokens;
expect(baseline).toHaveLength(78);
expect(baselineTokens).toBeGreaterThan(500_000); // ~600k measured — a real phone book

// 2. Overlay: fix a lint finding without forking the spec
const rawLint = lintDocument(document());
const vague = rawLint.findings.filter((finding) => finding.code === 'vague-description');
expect(vague.length).toBeGreaterThan(0);
// finding.path is "METHOD /path" — validate the parse and pin the exact
// operation so a fixture regeneration fails loudly here, not downstream
const parsed = vague[0].path?.match(/^([A-Z]+) (\/\S+)$/);
expect(parsed).not.toBeNull();
const [, method, opPath] = parsed!;
expect(method).toBe('DELETE');
expect(opPath).toBe('/gists/{gist_id}');

const generator = await OpenAPIToolGenerator.fromJSON(document(), {
overlays: {
overlay: '1.0.0',
info: { title: 'Expand a vague description', version: '1.0.0' },
actions: [
{
target: `$.paths['${opPath}'].${method.toLowerCase()}`,
update: {
description:
'Curated via overlay: deletes the referenced gist permanently for the authenticated user; requires the gist id path parameter and returns 204 on success.',
},
},
],
},
});
const patchedLint = await generator.lint();
expect(patchedLint.findings.filter((finding) => finding.code === 'vague-description')).toHaveLength(
vague.length - 1,
);

// 3. Trimming: the budget shrinks by more than half, schemas stay valid
const trimmed = await generator.generateTools({
stripExamples: true,
maxDescriptionLength: 200,
maxProperties: 40,
maxSchemaDepth: 4,
});
const trimmedTokens = analyzeToolSet(trimmed).estimatedTokens;
expect(trimmedTokens).toBeLessThan(0.5 * baselineTokens);
expect(
compileAll(
trimmed.flatMap((tool) => [
{ label: `${tool.name} input`, schema: tool.inputSchema },
...(tool.outputSchema ? [{ label: `${tool.name} output`, schema: tool.outputSchema }] : []),
]),
),
).toEqual([]);

// 4. Filtering: a curated slice instead of the phone book
const gistsOnly = await generator.generateTools({ includeTags: ['gists'] });
expect(gistsOnly).toHaveLength(20);
expect(analyzeToolSet(gistsOnly).estimatedTokens).toBeLessThan(baselineTokens / 3);
});
});
42 changes: 42 additions & 0 deletions e2e/fixtures/NOTICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Third-Party License Notices

Full license texts for the vendored fixture portions in this directory, copied verbatim from the pinned upstream commits recorded in [README.md](./README.md). The MIT license requires these notices to accompany the redistributed portions.

## github-trimmed-3.0.json — github/rest-api-description (commit b26c240)

> MIT License
>
> Copyright (c) 2020 GitHub
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all
> copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
> SOFTWARE.

## discord-trimmed-3.1.json — discord/discord-api-spec (commit 1314ec6)

> MIT License
>
> Copyright 2023 Discord
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## petstore-3.0.yaml — swagger-api/swagger-petstore (commit 8f0dd28)

Licensed under Apache-2.0; the license requires no notice reproduction for unmodified redistribution beyond attribution, recorded in [README.md](./README.md) with the upstream LICENSE link.
33 changes: 33 additions & 0 deletions e2e/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# E2E Fixtures — Vendored Real-World OpenAPI Specs

Real specs for the e2e story suite. **Unit tests never use fixture files** (inline specs only — see CLAUDE.md); these exist so the e2e stories exercise real-world spec weirdness that hand-built specs can't anticipate. They already earned their keep: vendoring them surfaced two product bugs (cyclic-schema infinite recursion in `toJsonSchema`, path-item-level parameters ignored by the validator).

## Files

| File | Source | Pinned commit | Retrieved | License | Size |
| ---- | ------ | ------------- | --------- | ------- | ---- |
| `petstore-3.0.yaml` | [swagger-api/swagger-petstore](https://github.com/swagger-api/swagger-petstore) `src/main/resources/openapi.yaml` (OAS 3.0.4) | `8f0dd286987880b4af7bce552aca3813166f3049` | 2026-08-13 | Apache-2.0 ([upstream LICENSE](https://github.com/swagger-api/swagger-petstore/blob/master/LICENSE)) | ~23 KB, vendored whole |
| `github-trimmed-3.0.json` | [github/rest-api-description](https://github.com/github/rest-api-description) `descriptions/api.github.com/api.github.com.json` (OAS 3.0.3, 12.9 MB upstream) | `b26c240ded1c8b79cb0fb09dee4a21239061fa23` | 2026-08-13 | MIT — © GitHub, Inc. ([upstream LICENSE](https://github.com/github/rest-api-description/blob/main/LICENSE.md)) | ~579 KB (78 operations) |
| `discord-trimmed-3.1.json` | [discord/discord-api-spec](https://github.com/discord/discord-api-spec) `specs/openapi.json` (OAS **3.1.0**, 1.18 MB upstream; dense `const`/`prefixItems`/`oneOf`, cyclic schemas) | `1314ec6fee3b2fdfb2c09b85fb49e467f84c1dd7` | 2026-08-13 | MIT — © Discord Inc. ([upstream LICENSE](https://github.com/discord/discord-api-spec/blob/main/LICENSE)) | ~410 KB (60 operations) |

The MIT license texts are vendored verbatim in [NOTICES.md](./NOTICES.md), as required for the redistributed portions; the trimmed files carry an `info['x-fixture-provenance']` note pointing back here.

## Regenerating

Trimming is done by the vendored [`trim-openapi.mjs`](https://github.com/agentfront/mcp-from-openapi/blob/main/e2e/fixtures/trim-openapi.mjs) (operation filter by tags or path prefixes + transitive `$ref` component closure, stable key order). Exact invocations:

```bash
PET_SHA=8f0dd286987880b4af7bce552aca3813166f3049
curl -fsSL "https://raw.githubusercontent.com/swagger-api/swagger-petstore/${PET_SHA}/src/main/resources/openapi.yaml" \
-o e2e/fixtures/petstore-3.0.yaml

GH_SHA=b26c240ded1c8b79cb0fb09dee4a21239061fa23
curl -fsSL "https://raw.githubusercontent.com/github/rest-api-description/${GH_SHA}/descriptions/api.github.com/api.github.com.json" -o /tmp/github-full.json
node e2e/fixtures/trim-openapi.mjs /tmp/github-full.json e2e/fixtures/github-trimmed-3.0.json --tags issues,gists

DIS_SHA=1314ec6fee3b2fdfb2c09b85fb49e467f84c1dd7
curl -fsSL "https://raw.githubusercontent.com/discord/discord-api-spec/${DIS_SHA}/specs/openapi.json" -o /tmp/discord-full.json
node e2e/fixtures/trim-openapi.mjs /tmp/discord-full.json e2e/fixtures/discord-trimmed-3.1.json --path-prefixes "/channels/{channel_id},/users"
```

**Regenerating a fixture (new commit or different trim) requires updating the literal tool-count and lint-finding assertions in `e2e/real-specs.e2e.ts` and `e2e/curation-journey.e2e.ts`** — the literals pin fixture↔spec drift on purpose.
Loading