swagger-digest 0.2.0 → 0.3.0

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
@@ -91,6 +91,37 @@ By default the digest is returned directly in the tool result (so it lands in th
91
91
  - **No headless-browser fallback:** the optional Playwright fallback is disabled in the MCP server.
92
92
  - Diagnostics go to stderr only; stdout carries protocol frames exclusively.
93
93
 
94
+ ## Benchmarks
95
+
96
+ Ten public APIs, digest vs. raw spec JSON in an LLM context, ground truth from the parsed raw specs. Run in November 2026 against swagger-digest 0.2.0; token counts are heuristic estimates, not tokenizer-exact.
97
+
98
+ **7/10 wins · 100% operation coverage on every digest produced · 1.6–3.7× lighter · 1 call vs 1–5**
99
+
100
+ | Spec | Ops | Raw tokens | Digest tokens | Compression | Op coverage | Winner |
101
+ | ------------------- | ----- | ---------------------- | ----------------------- | ----------- | ----------- | ------- |
102
+ | Petstore v2 | 20 | 3,461 | 2,202 | 1.6× | 20/20 | 🟢 MCP |
103
+ | Petstore v3 | 19 | 4,277 | 2,624 | 1.6× | 19/19 | 🟢 MCP |
104
+ | Petstore v3 UI page | 19 | 4,277 (+4 URL guesses) | ❌ failed* | — | — | 🟡 Raw* |
105
+ | Azure Compute | 157 | 157,568 | 72,549 | 2.2× | 157/157 | 🟢 MCP |
106
+ | X/Twitter v2 | 259 | 333,985 | 105,719 | 3.2× | 259/259 | 🟢 MCP |
107
+ | Box | 297 | 453,200 | 162,829 | 2.8× | 297/297 | 🟢 MCP |
108
+ | Twilio | 197 | 469,913 | 155,709 | 3.0× | 197/197 | 🟢 MCP |
109
+ | Stripe | 612 | 2,079,211 | 564,308 | 3.7× | 612/612 | 🟢 MCP |
110
+ | Kubernetes | 1,172 | 1,085,939 | 1,341,885 (expanded!) | ❌ 0.8×* | 1172/1172 | 🔴 Raw |
111
+ | GitHub (full) | 1,272 | 3,551,639 | ❌ failed (14 MB fetch) | — | — | 🟡 Raw* |
112
+
113
+ > For its target use case — understanding an API in an LLM context — the digest wins on the spec class that actually matters (up to ~600 operations): 100% endpoint coverage, never truncated, one call, and 1.6–3.7× lighter. The bigger the spec, the bigger the win.
114
+
115
+ \* Raw's three "wins" carry heavy caveats: the Petstore UI-page case needed brute-forced candidate URLs (the digest's discovery has since been fixed for that class of pages); GitHub's 3.55M-token raw spec would truncate in any model's context anyway; and the Kubernetes expansion is now prevented by the default token cap described below.
116
+
117
+ ### Known failure modes (and their mitigations)
118
+
119
+ | Case | What happens | Mitigation |
120
+ | ------------------------------------------------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
121
+ | JS-only Swagger UI pages with dynamically computed spec URLs | Literal extraction finds nothing after `url:` | **Fixed in 0.3.0** (embedded spec-URL scan). Otherwise pass `spec_url` / `--spec` explicitly |
122
+ | Mega-specs (Kubernetes-class, ≥1M tokens) | Digest can _expand_ past raw size — descriptions inlined per operation | **Default in 0.3.0**: automatic 250k-token cap (opt out with `--no-auto-trim` / `auto_trim: false`). Explicit `max_tokens` always wins |
123
+ | Huge fetches (14 MB GitHub spec) | Fetch fails at the 10 MB response cap | Use a filtered direct spec URL |
124
+
94
125
  ### Filtering and token budget
95
126
 
96
127
  Large specs produce large digests. The CLI can remove whole operations before rendering and/or trim the remaining digest to an approximate token budget:
@@ -116,6 +147,8 @@ How `--max-tokens` trims (in this fixed order, re-checked against the estimate a
116
147
 
117
148
  A `## Trimmed` note at the end of the digest records exactly which steps were applied. After every step, schemas that became unreachable (referenced only by collapsed/removed operations) are dropped from the Data types section. The token estimator is a heuristic, **not** a real tokenizer — treat `--max-tokens` as a guide, not a guarantee. Unknown tag names passed to `--include-tag`/`--exclude-tag` produce a warning on stderr listing the tags that exist in the spec.
118
149
 
150
+ **Default token cap:** without an explicit `--max-tokens`, digests are automatically trimmed to an estimated 250,000 tokens (a warning names the untrimmed estimate). Mega-specs can otherwise expand _past_ their raw size once descriptions are inlined per operation. Opt out with `--no-auto-trim`; an explicit `--max-tokens` always takes precedence. The MCP server behaves the same via its `auto_trim` argument (default true).
151
+
119
152
  The same options are available programmatically via the third argument of `transformOpenApiToMarkdown(spec, sourceUrl, { includeTags, excludeTags, pathPrefix, maxTokens, onWarning })`.
120
153
 
121
154
  ## What it extracts
@@ -123,7 +156,7 @@ The same options are available programmatically via the third argument of `trans
123
156
  The retrieval strategy deliberately avoids scraping rendered operation accordions or simulating UI clicks:
124
157
 
125
158
  1. Fetch and parse a direct `.json`, `.yaml`, or `.yml` URL.
126
- 2. Fetch the Swagger UI page and inspect script configuration for `url: "..."` or an inline `spec: { ... }` object.
159
+ 2. Fetch the Swagger UI page and inspect script configuration for `url: "..."` or an inline `spec: { ... }` object, plus any spec-like URLs embedded in same-origin initializer scripts (covers initializers that compute the spec URL dynamically).
127
160
  3. Try common spec endpoints on the same origin: `/v3/api-docs`, `/swagger/v1/swagger.json`, `/swagger.json`, `/openapi.json`, and `/api-docs`.
128
161
  4. Optionally launch a headless Chromium browser and read the parsed specification from Swagger UI's in-memory state.
129
162
 
@@ -6,7 +6,7 @@ import {
6
6
  parseHeaderPairs,
7
7
  transformOpenApiToMarkdown,
8
8
  writeDigest
9
- } from "./chunk-U7OLLL6J.js";
9
+ } from "./chunk-MXTHI25H.js";
10
10
 
11
11
  // src/mcp/digest.ts
12
12
  var TOKEN_HINT_THRESHOLD = 8e4;
@@ -69,6 +69,7 @@ async function runSwaggerDigest(args, options = {}) {
69
69
  excludeTags: args.exclude_tags,
70
70
  pathPrefix: args.path_prefix,
71
71
  maxTokens: args.max_tokens,
72
+ autoTrim: args.auto_trim !== false,
72
73
  onWarning: warn
73
74
  });
74
75
  const operationCount = countDigestOperations(markdown);
@@ -128,6 +129,9 @@ function createMcpServer(options = {}) {
128
129
  max_tokens: z.number().int().positive().optional().describe(
129
130
  "Trim the digest until an approximate token estimate fits this budget (heuristic, not tokenizer-accurate)"
130
131
  ),
132
+ auto_trim: z.boolean().optional().describe(
133
+ "Auto-trim digests exceeding the 250,000-token default budget when max_tokens is not given. Defaults to true."
134
+ ),
131
135
  write_to_file: z.object({ directory: z.string(), filename: z.string() }).optional().describe("Save the digest to a file instead of returning it inline")
132
136
  }
133
137
  },
@@ -586,6 +586,35 @@ function extractSpecUrlsFromScripts(scripts, pageUrl) {
586
586
  }
587
587
  return found;
588
588
  }
589
+ function extractEmbeddedSpecUrlsFromScripts(scripts, pageUrl) {
590
+ const found = [];
591
+ const push = (raw) => {
592
+ try {
593
+ const url = new URL(raw);
594
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
595
+ if (!/\.(?:json|ya?ml)$/i.test(url.pathname)) return;
596
+ if (!/(?:openapi|swagger|api[-._]?docs)/i.test(url.pathname)) return;
597
+ const resolved = url.toString();
598
+ if (!found.includes(resolved)) found.push(resolved);
599
+ } catch {
600
+ }
601
+ };
602
+ for (const script of scripts) {
603
+ for (const match of script.matchAll(/https?:\/\/[^\s"'`<>(){}[\]\\,=]+/gi)) {
604
+ push(match[0]);
605
+ }
606
+ }
607
+ const sameOrigin = found.filter((url) => {
608
+ try {
609
+ return new URL(url).origin === pageUrl.origin;
610
+ } catch {
611
+ return false;
612
+ }
613
+ });
614
+ const crossOrigin = found.filter((url) => !sameOrigin.includes(url));
615
+ return [...sameOrigin, ...crossOrigin].slice(0, MAX_EMBEDDED_SPEC_URLS);
616
+ }
617
+ var MAX_EMBEDDED_SPEC_URLS = 8;
589
618
  function findObjectLiteralArrayAfterKey(source, key) {
590
619
  const keyPattern = new RegExp(`\\b${key}\\s*:`);
591
620
  const match = keyPattern.exec(source);
@@ -703,7 +732,7 @@ async function fetchExternalConfigScripts(srcs, pageUrl, ctx, errors) {
703
732
  try {
704
733
  const response = await fetchText(candidate.toString(), ctx);
705
734
  scripts.push(response.text);
706
- if (extractSpecUrlFromScripts(scripts, pageUrl) || scripts.some((script) => findObjectLiteralAfterKey(script, "spec"))) {
735
+ if (extractSpecUrlFromScripts(scripts, pageUrl) || extractEmbeddedSpecUrlsFromScripts(scripts, pageUrl).length > 0 || scripts.some((script) => findObjectLiteralAfterKey(script, "spec"))) {
707
736
  break;
708
737
  }
709
738
  } catch (error) {
@@ -983,6 +1012,13 @@ async function fetchOpenApiSpec(input, options = {}) {
983
1012
  const externalUrls = extractSpecUrls(externalScripts, pageUrl).filter((url) => !inlineUrls.includes(url));
984
1013
  const fromExternal = await probeSpecUrls([...inlineUrls, ...externalUrls], ctx, recordFailure, noteMultiple);
985
1014
  if (fromExternal) return fromExternal;
1015
+ const triedUrls = /* @__PURE__ */ new Set([...inlineUrls, ...externalUrls]);
1016
+ const embeddedUrls = [
1017
+ ...extractEmbeddedSpecUrlsFromScripts(scripts, pageUrl),
1018
+ ...extractEmbeddedSpecUrlsFromScripts(externalScripts, pageUrl)
1019
+ ].filter((url) => !triedUrls.has(url));
1020
+ const fromEmbedded = await probeSpecUrls(embeddedUrls, ctx, recordFailure);
1021
+ if (fromEmbedded) return fromEmbedded;
986
1022
  for (const script of scripts) {
987
1023
  const literal = findObjectLiteralAfterKey(script, "spec");
988
1024
  if (!literal) continue;
@@ -1904,6 +1940,7 @@ function normalizeInlinePayloadSchemas(spec) {
1904
1940
  }
1905
1941
 
1906
1942
  // src/transformer/index.ts
1943
+ var DEFAULT_MAX_TOKENS = 25e4;
1907
1944
  var HTTP_METHODS2 = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
1908
1945
  function tagsOfEndpoint(endpoint) {
1909
1946
  const tags = endpoint.operation.tags;
@@ -2076,16 +2113,22 @@ function transformOpenApiToMarkdown(spec, sourceUrl, options = {}) {
2076
2113
  collapsedTags: /* @__PURE__ */ new Set(),
2077
2114
  indexOnly: false
2078
2115
  };
2079
- if (options.maxTokens === void 0) {
2080
- return renderDigest(normalizedSpec, sourceUrl, endpoints, tags, fullStage);
2081
- }
2082
2116
  const worst = (markdown2) => {
2083
2117
  const report = estimateTokens(markdown2);
2084
2118
  return Math.max(...Object.values(report.models).map((model) => model.tokens));
2085
2119
  };
2086
- const fits = (markdown2) => worst(markdown2) <= options.maxTokens;
2120
+ const effectiveMaxTokens = options.maxTokens ?? (options.autoTrim === false ? void 0 : DEFAULT_MAX_TOKENS);
2121
+ if (effectiveMaxTokens === void 0) {
2122
+ return renderDigest(normalizedSpec, sourceUrl, endpoints, tags, fullStage);
2123
+ }
2124
+ const fits = (markdown2) => worst(markdown2) <= effectiveMaxTokens;
2087
2125
  const full = renderDigest(normalizedSpec, sourceUrl, endpoints, tags, fullStage);
2088
2126
  if (fits(full)) return full;
2127
+ if (options.maxTokens === void 0) {
2128
+ options.onWarning?.(
2129
+ `Untrimmed digest \u2248 ${worst(full).toLocaleString()} tokens (estimated), over the ${DEFAULT_MAX_TOKENS.toLocaleString()} default budget; auto-trimming. Pass maxTokens to control the budget or autoTrim: false to disable.`
2130
+ );
2131
+ }
2089
2132
  const stages = buildTrimStages(endpoints, tags);
2090
2133
  for (let i = 1; i < stages.length; i += 1) {
2091
2134
  const stage = stages[i];
@@ -2094,14 +2137,14 @@ function transformOpenApiToMarkdown(spec, sourceUrl, options = {}) {
2094
2137
  return appendTrimNote(
2095
2138
  markdown2,
2096
2139
  stages.slice(1, i + 1).map((entry) => entry.action).filter((action) => Boolean(action)),
2097
- options.maxTokens
2140
+ effectiveMaxTokens
2098
2141
  );
2099
2142
  }
2100
2143
  }
2101
2144
  const last = stages[stages.length - 1];
2102
2145
  const markdown = renderDigest(normalizedSpec, sourceUrl, endpoints, tags, last);
2103
2146
  const allActions = stages.slice(1).map((stage) => stage.action).filter((action) => Boolean(action));
2104
- return appendTrimNote(markdown, allActions, options.maxTokens);
2147
+ return appendTrimNote(markdown, allActions, effectiveMaxTokens);
2105
2148
  }
2106
2149
 
2107
2150
  // src/headers.ts
@@ -2196,6 +2239,7 @@ export {
2196
2239
  parseSpecText,
2197
2240
  fetchOpenApiSpec,
2198
2241
  estimateTokens,
2242
+ DEFAULT_MAX_TOKENS,
2199
2243
  transformOpenApiToMarkdown,
2200
2244
  parseHeaderArgument,
2201
2245
  parseHeaderPairs,
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  parseHeaderPairs,
11
11
  transformOpenApiToMarkdown,
12
12
  writeDigest
13
- } from "./chunk-U7OLLL6J.js";
13
+ } from "./chunk-MXTHI25H.js";
14
14
 
15
15
  // src/cli.ts
16
16
  import { input } from "@inquirer/prompts";
@@ -61,7 +61,7 @@ async function main() {
61
61
  "Custom request header, repeatable. Sent only to the URL you supplied.",
62
62
  collectOption,
63
63
  []
64
- ).option("--cookie <string>", "Convenience for a Cookie request header (same-origin scoping applies)").option("--include-tag <tag>", "Keep only operations with this tag, repeatable", collectOption, []).option("--exclude-tag <tag>", "Drop operations with this tag, repeatable", collectOption, []).option("--path-prefix <prefix>", "Keep only operations whose path starts with this prefix").option("--max-tokens <n>", "Trim the digest until the approximate token estimate fits this budget").option("--spec <url>", "Explicit OpenAPI spec URL; overrides discovery from the Swagger UI page").option("--stdout", "Print the digest to standard output instead of writing a file").addHelpText(
64
+ ).option("--cookie <string>", "Convenience for a Cookie request header (same-origin scoping applies)").option("--include-tag <tag>", "Keep only operations with this tag, repeatable", collectOption, []).option("--exclude-tag <tag>", "Drop operations with this tag, repeatable", collectOption, []).option("--path-prefix <prefix>", "Keep only operations whose path starts with this prefix").option("--max-tokens <n>", "Trim the digest until the approximate token estimate fits this budget").option("--no-auto-trim", "Disable the automatic 250,000-token default cap for digests without --max-tokens").option("--spec <url>", "Explicit OpenAPI spec URL; overrides discovery from the Swagger UI page").option("--stdout", "Print the digest to standard output instead of writing a file").addHelpText(
65
65
  "after",
66
66
  [
67
67
  "",
@@ -132,6 +132,7 @@ async function main() {
132
132
  excludeTags: options.excludeTag,
133
133
  pathPrefix: options.pathPrefix,
134
134
  maxTokens: parseMaxTokens(options.maxTokens),
135
+ autoTrim: options.autoTrim !== false,
135
136
  onWarning: (message) => console.error(chalk.yellow(`Warning: ${message}`))
136
137
  });
137
138
  spinner.succeed(`Parsed ${title} (${result.retrievalMethod})`);
package/dist/index.d.ts CHANGED
@@ -57,6 +57,11 @@ interface McpDigestArgs {
57
57
  path_prefix?: string;
58
58
  /** Trim the digest until the approximate token estimate fits this budget. */
59
59
  max_tokens?: number;
60
+ /**
61
+ * Auto-trim digests exceeding the 250,000-token default budget when
62
+ * max_tokens is not given. Defaults to true.
63
+ */
64
+ auto_trim?: boolean;
60
65
  /** Save the digest to a file instead of returning it inline. */
61
66
  write_to_file?: {
62
67
  directory: string;
@@ -114,9 +119,24 @@ interface TransformOptions {
114
119
  * budget. The estimator is not tokenizer-accurate; treat the budget as a guide.
115
120
  */
116
121
  maxTokens?: number;
122
+ /**
123
+ * When no explicit `maxTokens` is given, cap the digest at
124
+ * {@link DEFAULT_MAX_TOKENS} estimated tokens by running the same trim
125
+ * pipeline, warning when it engages. Defaults to true. Mega-specs
126
+ * (Kubernetes-class) otherwise produce digests *larger* than the raw spec.
127
+ */
128
+ autoTrim?: boolean;
117
129
  /** Receives non-fatal notices, e.g. unknown tag names. */
118
130
  onWarning?: (message: string) => void;
119
131
  }
132
+ /**
133
+ * Default token budget applied when `maxTokens` is undefined and `autoTrim`
134
+ * is not disabled. Chosen from benchmark data: large real-world specs
135
+ * (Stripe-class, ~600 operations) digest to ~560k tokens untrimmed, while
136
+ * kitchen-sink specs expand past their raw size — 250k keeps the largest
137
+ * useful full-detail digests intact and trims only true outliers.
138
+ */
139
+ declare const DEFAULT_MAX_TOKENS = 250000;
120
140
  /** Convert an OpenAPI 2.0, 3.0, or 3.1 document to a compact Markdown digest. */
121
141
  declare function transformOpenApiToMarkdown(spec: JsonObject, sourceUrl?: string, options?: TransformOptions): string;
122
142
 
@@ -153,5 +173,5 @@ declare function writeDigest(options: {
153
173
  filename: string;
154
174
  }): Promise<string>;
155
175
 
156
- export { buildRequestHeaders, createMcpServer, defaultFilename, defaultOutputDirectory, estimateTokens, fetchOpenApiSpec, isOpenApiSpec, normalizeFilename, parseSpecText, resolveOutputDirectory, runSwaggerDigest, toTypeScriptIdentifier, transformOpenApiToMarkdown, writeDigest };
176
+ export { DEFAULT_MAX_TOKENS, buildRequestHeaders, createMcpServer, defaultFilename, defaultOutputDirectory, estimateTokens, fetchOpenApiSpec, isOpenApiSpec, normalizeFilename, parseSpecText, resolveOutputDirectory, runSwaggerDigest, toTypeScriptIdentifier, transformOpenApiToMarkdown, writeDigest };
157
177
  export type { DigestServerOptions, DigestTokenReport, FetchOptions, FetchResult, JsonObject, McpDigestArgs, ModelTokenEstimate, TransformOptions };
package/dist/index.js CHANGED
@@ -2,8 +2,9 @@ import {
2
2
  buildRequestHeaders,
3
3
  createMcpServer,
4
4
  runSwaggerDigest
5
- } from "./chunk-2NORV6KM.js";
5
+ } from "./chunk-DJES77HX.js";
6
6
  import {
7
+ DEFAULT_MAX_TOKENS,
7
8
  defaultFilename,
8
9
  defaultOutputDirectory,
9
10
  estimateTokens,
@@ -15,8 +16,9 @@ import {
15
16
  toTypeScriptIdentifier,
16
17
  transformOpenApiToMarkdown,
17
18
  writeDigest
18
- } from "./chunk-U7OLLL6J.js";
19
+ } from "./chunk-MXTHI25H.js";
19
20
  export {
21
+ DEFAULT_MAX_TOKENS,
20
22
  buildRequestHeaders,
21
23
  createMcpServer,
22
24
  defaultFilename,
package/dist/mcp/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createMcpServer
4
- } from "../chunk-2NORV6KM.js";
5
- import "../chunk-U7OLLL6J.js";
4
+ } from "../chunk-DJES77HX.js";
5
+ import "../chunk-MXTHI25H.js";
6
6
 
7
7
  // src/mcp/index.ts
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swagger-digest",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Turn Swagger UI pages and OpenAPI specs into compact, AI-friendly Markdown API digests.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",