swagger-digest 0.1.2 → 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
@@ -45,6 +45,83 @@ npx swagger-digest --url https://api.example.com/openapi.json --stdout | pbcopy
45
45
 
46
46
  Run `npx swagger-digest --help` for all CLI options. The package requires Node.js 20 or newer.
47
47
 
48
+ ## MCP server
49
+
50
+ The package ships an [MCP](https://modelcontextprotocol.io) server exposing the same pipeline as a tool for AI agents, so they can fetch and digest API docs on their own instead of being handed a Markdown file.
51
+
52
+ ```bash
53
+ npx swagger-digest-mcp
54
+ ```
55
+
56
+ It speaks the stdio transport: the MCP client spawns the process and communicates over stdin/stdout. Register it in your client's configuration:
57
+
58
+ ```json
59
+ {
60
+ "mcp": {
61
+ "swagger-digest": {
62
+ "command": ["npx", "-y", "swagger-digest-mcp"]
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ### The `swagger_digest` tool
69
+
70
+ | Argument | Type | Description |
71
+ | --------------- | ---------------- | -------------------------------------------------------------------------- |
72
+ | `url` | string, required | Swagger UI page URL or direct OpenAPI spec URL |
73
+ | `spec_url` | string, optional | Explicit spec URL; overrides discovery from the page |
74
+ | `headers` | object, optional | Request headers (e.g. `Authorization`); sent only to the origin of `url` |
75
+ | `include_tags` | string[] | Keep only operations with these tags |
76
+ | `exclude_tags` | string[] | Drop operations with these tags |
77
+ | `path_prefix` | string | Keep only paths starting with this prefix |
78
+ | `max_tokens` | number | Trim the digest to an approximate token budget |
79
+ | `write_to_file` | object, optional | `{ directory, filename }` — save the digest instead of returning it inline |
80
+
81
+ By default the digest is returned directly in the tool result (so it lands in the agent's context), with a metadata footer: retrieval method, operation/model counts, and a token estimate.
82
+
83
+ ### Environment variables
84
+
85
+ - `SWAGGER_DIGEST_HEADER` — newline-separated `Name: value` pairs, merged with (and overridden by) the tool call's `headers` argument. Keeps secrets out of the conversation.
86
+ - `SWAGGER_DIGEST_ALLOW_PRIVATE=1` — opt out of the private-network guard (see below).
87
+
88
+ ### Security differences from the CLI
89
+
90
+ - **SSRF guard on by default:** URLs resolving to loopback, private, or link-local (cloud metadata) addresses are rejected, because MCP clients supply URLs on the model's behalf. Opt out with `SWAGGER_DIGEST_ALLOW_PRIVATE=1` — for example to digest an internal corporate API — only if you trust the hosts involved.
91
+ - **No headless-browser fallback:** the optional Playwright fallback is disabled in the MCP server.
92
+ - Diagnostics go to stderr only; stdout carries protocol frames exclusively.
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
+
48
125
  ### Filtering and token budget
49
126
 
50
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:
@@ -70,6 +147,8 @@ How `--max-tokens` trims (in this fixed order, re-checked against the estimate a
70
147
 
71
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.
72
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
+
73
152
  The same options are available programmatically via the third argument of `transformOpenApiToMarkdown(spec, sourceUrl, { includeTags, excludeTags, pathPrefix, maxTokens, onWarning })`.
74
153
 
75
154
  ## What it extracts
@@ -77,7 +156,7 @@ The same options are available programmatically via the third argument of `trans
77
156
  The retrieval strategy deliberately avoids scraping rendered operation accordions or simulating UI clicks:
78
157
 
79
158
  1. Fetch and parse a direct `.json`, `.yaml`, or `.yml` URL.
80
- 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).
81
160
  3. Try common spec endpoints on the same origin: `/v3/api-docs`, `/swagger/v1/swagger.json`, `/swagger.json`, `/openapi.json`, and `/api-docs`.
82
161
  4. Optionally launch a headless Chromium browser and read the parsed specification from Swagger UI's in-memory state.
83
162
 
@@ -179,6 +258,10 @@ src/
179
258
  transformer.ts OpenAPI/Swagger to compact Markdown and TypeScript-like types
180
259
  writer.ts Output path, filename defaults and file writing
181
260
  index.ts Public exports
261
+ mcp/
262
+ index.ts MCP stdio entry point (`swagger-digest-mcp` bin)
263
+ server.ts MCP server and tool registration (transport-agnostic)
264
+ digest.ts Tool handler: fetch → transform → tool result
182
265
  ```
183
266
 
184
267
  ## Limitations
@@ -0,0 +1,147 @@
1
+ import {
2
+ VERSION,
3
+ estimateTokens,
4
+ fetchOpenApiSpec,
5
+ mergeHeaders,
6
+ parseHeaderPairs,
7
+ transformOpenApiToMarkdown,
8
+ writeDigest
9
+ } from "./chunk-MXTHI25H.js";
10
+
11
+ // src/mcp/digest.ts
12
+ var TOKEN_HINT_THRESHOLD = 8e4;
13
+ function textContent(text) {
14
+ return [{ type: "text", text }];
15
+ }
16
+ function asError(message) {
17
+ return { content: textContent(message), isError: true };
18
+ }
19
+ function isHttpUrl(value) {
20
+ try {
21
+ const url = new URL(value);
22
+ return url.protocol === "http:" || url.protocol === "https:";
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+ function countDigestOperations(markdown) {
28
+ return (markdown.match(/^\| \*\*(?:GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE)\*\* \|/gm) ?? []).length;
29
+ }
30
+ function countDigestModels(markdown) {
31
+ return (markdown.match(/^export (?:interface|type) \w+/gm) ?? []).length;
32
+ }
33
+ function buildRequestHeaders(args) {
34
+ const fromEnv = process.env.SWAGGER_DIGEST_HEADER ? parseHeaderPairs(process.env.SWAGGER_DIGEST_HEADER) : {};
35
+ return mergeHeaders(fromEnv, args.headers);
36
+ }
37
+ async function runSwaggerDigest(args, options = {}) {
38
+ if (!isHttpUrl(args.url)) {
39
+ return asError(`Invalid url "${args.url}": use an http:// or https:// URL.`);
40
+ }
41
+ if (args.spec_url !== void 0 && !isHttpUrl(args.spec_url)) {
42
+ return asError(`Invalid spec_url "${args.spec_url}": use an http:// or https:// URL.`);
43
+ }
44
+ if (args.max_tokens !== void 0 && (!Number.isInteger(args.max_tokens) || args.max_tokens <= 0)) {
45
+ return asError(`Invalid max_tokens ${args.max_tokens}: use a positive integer.`);
46
+ }
47
+ const blockPrivateNetworks = options.blockPrivateNetworks ?? process.env.SWAGGER_DIGEST_ALLOW_PRIVATE !== "1";
48
+ const warnings = [];
49
+ const warn = (message) => {
50
+ warnings.push(message);
51
+ };
52
+ let fetchResult;
53
+ try {
54
+ const fetchOptions = {
55
+ useBrowserFallback: false,
56
+ maxBytes: 1e7,
57
+ headers: buildRequestHeaders(args),
58
+ specUrl: args.spec_url,
59
+ blockPrivateNetworks,
60
+ warn
61
+ };
62
+ fetchResult = await fetchOpenApiSpec(args.url, fetchOptions);
63
+ } catch (error) {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ return asError(`Could not extract an OpenAPI definition from ${args.url}: ${message}`);
66
+ }
67
+ const markdown = transformOpenApiToMarkdown(fetchResult.spec, fetchResult.sourceUrl, {
68
+ includeTags: args.include_tags,
69
+ excludeTags: args.exclude_tags,
70
+ pathPrefix: args.path_prefix,
71
+ maxTokens: args.max_tokens,
72
+ autoTrim: args.auto_trim !== false,
73
+ onWarning: warn
74
+ });
75
+ const operationCount = countDigestOperations(markdown);
76
+ const modelCount = countDigestModels(markdown);
77
+ const tokens = estimateTokens(markdown).models.claude.tokens;
78
+ const metadata = [
79
+ `source: ${fetchResult.sourceUrl} (via ${fetchResult.retrievalMethod})`,
80
+ `${operationCount} operation${operationCount === 1 ? "" : "s"}`,
81
+ `${modelCount} data model${modelCount === 1 ? "" : "s"}`,
82
+ `~${tokens.toLocaleString("en-US")} tokens estimated`
83
+ ].join(" \xB7 ");
84
+ const warningBlock = warnings.length > 0 ? `
85
+
86
+ _Warnings:_
87
+ ${warnings.map((w) => `- ${w}`).join("\n")}` : "";
88
+ const budgetHint = tokens > TOKEN_HINT_THRESHOLD && args.max_tokens === void 0 ? "\n\n_This digest is large. Consider re-running with max_tokens, include_tags, exclude_tags or path_prefix to fit it into context._" : "";
89
+ if (args.write_to_file) {
90
+ try {
91
+ const outputPath = await writeDigest({
92
+ markdown,
93
+ directory: args.write_to_file.directory,
94
+ filename: args.write_to_file.filename
95
+ });
96
+ return { content: textContent(`Digest written to ${outputPath}
97
+
98
+ _${metadata}_${warningBlock}`) };
99
+ } catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ return asError(`Could not write digest: ${message}`);
102
+ }
103
+ }
104
+ return { content: textContent(`${markdown}
105
+
106
+ ---
107
+ _Digest metadata: ${metadata}_${warningBlock}${budgetHint}`) };
108
+ }
109
+
110
+ // src/mcp/server.ts
111
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
112
+ import { z } from "zod";
113
+ function createMcpServer(options = {}) {
114
+ const server = new McpServer({ name: "swagger-digest", version: VERSION });
115
+ server.registerTool(
116
+ "swagger_digest",
117
+ {
118
+ title: "Generate API digest",
119
+ description: "Fetch a Swagger UI page or an OpenAPI/Swagger spec (JSON or YAML, versions 2.0/3.0/3.1) and convert it into a compact Markdown API reference optimized for LLM context: endpoint index, request/response details, authentication schemes, and TypeScript-like data types. Handles Swagger UI discovery automatically (initializer scripts, multi-spec dropdowns, common spec paths). Use this whenever the user asks to understand, summarize, or work against an API documented with Swagger/OpenAPI.",
120
+ inputSchema: {
121
+ url: z.string().describe("Swagger UI page URL or direct OpenAPI spec URL (http/https)"),
122
+ spec_url: z.string().optional().describe("Explicit OpenAPI spec URL; overrides discovery from the Swagger UI page"),
123
+ headers: z.record(z.string(), z.string()).optional().describe(
124
+ "Request headers (e.g. Authorization or Cookie). Sent ONLY to the origin of url; cross-origin requests never receive them. Values are not logged."
125
+ ),
126
+ include_tags: z.array(z.string()).optional().describe("Keep only operations carrying at least one of these tags"),
127
+ exclude_tags: z.array(z.string()).optional().describe("Drop operations carrying any of these tags"),
128
+ path_prefix: z.string().optional().describe("Keep only operations whose path starts with this prefix"),
129
+ max_tokens: z.number().int().positive().optional().describe(
130
+ "Trim the digest until an approximate token estimate fits this budget (heuristic, not tokenizer-accurate)"
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
+ ),
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")
136
+ }
137
+ },
138
+ (args) => runSwaggerDigest(args, options)
139
+ );
140
+ return server;
141
+ }
142
+
143
+ export {
144
+ buildRequestHeaders,
145
+ runSwaggerDigest,
146
+ createMcpServer
147
+ };
@@ -1,14 +1,15 @@
1
1
  // src/version.ts
2
2
  import { createRequire } from "module";
3
3
  function readPackageVersion(moduleUrl) {
4
- try {
5
- const require2 = createRequire(moduleUrl);
6
- const pkg = require2("../package.json");
7
- if (typeof pkg?.version === "string" && pkg.version) return pkg.version;
8
- return "unknown";
9
- } catch {
10
- return "unknown";
4
+ const require2 = createRequire(moduleUrl);
5
+ for (const candidate of ["../package.json", "../../package.json"]) {
6
+ try {
7
+ const pkg = require2(candidate);
8
+ if (typeof pkg?.version === "string" && pkg.version) return pkg.version;
9
+ } catch {
10
+ }
11
11
  }
12
+ return "unknown";
12
13
  }
13
14
  var VERSION = readPackageVersion(import.meta.url);
14
15
  var USER_AGENT = `swagger-digest/${VERSION}`;
@@ -585,6 +586,35 @@ function extractSpecUrlsFromScripts(scripts, pageUrl) {
585
586
  }
586
587
  return found;
587
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;
588
618
  function findObjectLiteralArrayAfterKey(source, key) {
589
619
  const keyPattern = new RegExp(`\\b${key}\\s*:`);
590
620
  const match = keyPattern.exec(source);
@@ -702,7 +732,7 @@ async function fetchExternalConfigScripts(srcs, pageUrl, ctx, errors) {
702
732
  try {
703
733
  const response = await fetchText(candidate.toString(), ctx);
704
734
  scripts.push(response.text);
705
- 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"))) {
706
736
  break;
707
737
  }
708
738
  } catch (error) {
@@ -982,6 +1012,13 @@ async function fetchOpenApiSpec(input, options = {}) {
982
1012
  const externalUrls = extractSpecUrls(externalScripts, pageUrl).filter((url) => !inlineUrls.includes(url));
983
1013
  const fromExternal = await probeSpecUrls([...inlineUrls, ...externalUrls], ctx, recordFailure, noteMultiple);
984
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;
985
1022
  for (const script of scripts) {
986
1023
  const literal = findObjectLiteralAfterKey(script, "spec");
987
1024
  if (!literal) continue;
@@ -1903,6 +1940,7 @@ function normalizeInlinePayloadSchemas(spec) {
1903
1940
  }
1904
1941
 
1905
1942
  // src/transformer/index.ts
1943
+ var DEFAULT_MAX_TOKENS = 25e4;
1906
1944
  var HTTP_METHODS2 = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
1907
1945
  function tagsOfEndpoint(endpoint) {
1908
1946
  const tags = endpoint.operation.tags;
@@ -2075,16 +2113,22 @@ function transformOpenApiToMarkdown(spec, sourceUrl, options = {}) {
2075
2113
  collapsedTags: /* @__PURE__ */ new Set(),
2076
2114
  indexOnly: false
2077
2115
  };
2078
- if (options.maxTokens === void 0) {
2079
- return renderDigest(normalizedSpec, sourceUrl, endpoints, tags, fullStage);
2080
- }
2081
2116
  const worst = (markdown2) => {
2082
2117
  const report = estimateTokens(markdown2);
2083
2118
  return Math.max(...Object.values(report.models).map((model) => model.tokens));
2084
2119
  };
2085
- 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;
2086
2125
  const full = renderDigest(normalizedSpec, sourceUrl, endpoints, tags, fullStage);
2087
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
+ }
2088
2132
  const stages = buildTrimStages(endpoints, tags);
2089
2133
  for (let i = 1; i < stages.length; i += 1) {
2090
2134
  const stage = stages[i];
@@ -2093,14 +2137,69 @@ function transformOpenApiToMarkdown(spec, sourceUrl, options = {}) {
2093
2137
  return appendTrimNote(
2094
2138
  markdown2,
2095
2139
  stages.slice(1, i + 1).map((entry) => entry.action).filter((action) => Boolean(action)),
2096
- options.maxTokens
2140
+ effectiveMaxTokens
2097
2141
  );
2098
2142
  }
2099
2143
  }
2100
2144
  const last = stages[stages.length - 1];
2101
2145
  const markdown = renderDigest(normalizedSpec, sourceUrl, endpoints, tags, last);
2102
2146
  const allActions = stages.slice(1).map((stage) => stage.action).filter((action) => Boolean(action));
2103
- return appendTrimNote(markdown, allActions, options.maxTokens);
2147
+ return appendTrimNote(markdown, allActions, effectiveMaxTokens);
2148
+ }
2149
+
2150
+ // src/headers.ts
2151
+ var HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2152
+ function validateHeaderName(name) {
2153
+ if (!name) throw new Error('Header name must not be empty. Expected "Name: value".');
2154
+ if (/[\r\n]/.test(name)) {
2155
+ throw new Error(`Invalid header name: header names must not contain CR/LF characters.`);
2156
+ }
2157
+ if (!HEADER_NAME_PATTERN.test(name)) {
2158
+ throw new Error(`Invalid header name "${name}": use a valid HTTP header token (letters, digits and -_).`);
2159
+ }
2160
+ }
2161
+ function validateHeaderValue(name, value) {
2162
+ if (/[\r\n]/.test(value)) {
2163
+ throw new Error(`Invalid value for header "${name}": values must not contain CR/LF.`);
2164
+ }
2165
+ if (/\0/.test(value)) {
2166
+ throw new Error(`Invalid value for header "${name}": values must not contain NUL bytes.`);
2167
+ }
2168
+ }
2169
+ function parseHeaderArgument(raw) {
2170
+ const separator = raw.indexOf(":");
2171
+ if (separator === -1) {
2172
+ if (/[\r\n]/.test(raw)) {
2173
+ throw new Error(`Invalid header: names and values must not contain CR/LF.`);
2174
+ }
2175
+ throw new Error(`Invalid header \u2014 expected "Name: value" format (for example "Authorization: Bearer <token>").`);
2176
+ }
2177
+ const name = raw.slice(0, separator).trim();
2178
+ const value = raw.slice(separator + 1).trim();
2179
+ validateHeaderName(name);
2180
+ validateHeaderValue(name, value);
2181
+ return { [name.toLowerCase()]: value };
2182
+ }
2183
+ function parseHeaderPairs(raw) {
2184
+ const result = {};
2185
+ for (const line of raw.split(/\r?\n/)) {
2186
+ if (!line.trim()) continue;
2187
+ Object.assign(result, parseHeaderArgument(line));
2188
+ }
2189
+ return result;
2190
+ }
2191
+ function mergeHeaders(...sources) {
2192
+ const result = {};
2193
+ for (const source of sources) {
2194
+ if (!source) continue;
2195
+ for (const [name, value] of Object.entries(source)) {
2196
+ if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
2197
+ throw new Error(`Invalid header "${name}": CR/LF is not allowed in header names or values.`);
2198
+ }
2199
+ result[name.toLowerCase()] = value;
2200
+ }
2201
+ }
2202
+ return result;
2104
2203
  }
2105
2204
 
2106
2205
  // src/writer.ts
@@ -2140,7 +2239,11 @@ export {
2140
2239
  parseSpecText,
2141
2240
  fetchOpenApiSpec,
2142
2241
  estimateTokens,
2242
+ DEFAULT_MAX_TOKENS,
2143
2243
  transformOpenApiToMarkdown,
2244
+ parseHeaderArgument,
2245
+ parseHeaderPairs,
2246
+ mergeHeaders,
2144
2247
  defaultOutputDirectory,
2145
2248
  defaultFilename,
2146
2249
  resolveOutputDirectory,
package/dist/cli.js CHANGED
@@ -5,72 +5,18 @@ import {
5
5
  defaultOutputDirectory,
6
6
  estimateTokens,
7
7
  fetchOpenApiSpec,
8
+ mergeHeaders,
9
+ parseHeaderArgument,
10
+ parseHeaderPairs,
8
11
  transformOpenApiToMarkdown,
9
12
  writeDigest
10
- } from "./chunk-CBSDTIKN.js";
13
+ } from "./chunk-MXTHI25H.js";
11
14
 
12
15
  // src/cli.ts
13
16
  import { input } from "@inquirer/prompts";
14
17
  import chalk from "chalk";
15
18
  import { Command } from "commander";
16
19
  import ora from "ora";
17
-
18
- // src/headers.ts
19
- var HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
20
- function validateHeaderName(name) {
21
- if (!name) throw new Error('Header name must not be empty. Expected "Name: value".');
22
- if (/[\r\n]/.test(name)) {
23
- throw new Error(`Invalid header name: header names must not contain CR/LF characters.`);
24
- }
25
- if (!HEADER_NAME_PATTERN.test(name)) {
26
- throw new Error(`Invalid header name "${name}": use a valid HTTP header token (letters, digits and -_).`);
27
- }
28
- }
29
- function validateHeaderValue(name, value) {
30
- if (/[\r\n]/.test(value)) {
31
- throw new Error(`Invalid value for header "${name}": values must not contain CR/LF.`);
32
- }
33
- if (/\0/.test(value)) {
34
- throw new Error(`Invalid value for header "${name}": values must not contain NUL bytes.`);
35
- }
36
- }
37
- function parseHeaderArgument(raw) {
38
- const separator = raw.indexOf(":");
39
- if (separator === -1) {
40
- if (/[\r\n]/.test(raw)) {
41
- throw new Error(`Invalid header: names and values must not contain CR/LF.`);
42
- }
43
- throw new Error(`Invalid header \u2014 expected "Name: value" format (for example "Authorization: Bearer <token>").`);
44
- }
45
- const name = raw.slice(0, separator).trim();
46
- const value = raw.slice(separator + 1).trim();
47
- validateHeaderName(name);
48
- validateHeaderValue(name, value);
49
- return { [name.toLowerCase()]: value };
50
- }
51
- function parseHeaderPairs(raw) {
52
- const result = {};
53
- for (const line of raw.split(/\r?\n/)) {
54
- if (!line.trim()) continue;
55
- Object.assign(result, parseHeaderArgument(line));
56
- }
57
- return result;
58
- }
59
- function mergeHeaders(...sources) {
60
- const result = {};
61
- for (const source of sources) {
62
- if (!source) continue;
63
- for (const [name, value] of Object.entries(source)) {
64
- if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
65
- throw new Error(`Invalid header "${name}": CR/LF is not allowed in header names or values.`);
66
- }
67
- result[name.toLowerCase()] = value;
68
- }
69
- }
70
- return result;
71
- }
72
-
73
- // src/cli.ts
74
20
  function validateUrl(value) {
75
21
  try {
76
22
  const url = new URL(value);
@@ -115,7 +61,7 @@ async function main() {
115
61
  "Custom request header, repeatable. Sent only to the URL you supplied.",
116
62
  collectOption,
117
63
  []
118
- ).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(
119
65
  "after",
120
66
  [
121
67
  "",
@@ -186,6 +132,7 @@ async function main() {
186
132
  excludeTags: options.excludeTag,
187
133
  pathPrefix: options.pathPrefix,
188
134
  maxTokens: parseMaxTokens(options.maxTokens),
135
+ autoTrim: options.autoTrim !== false,
189
136
  onWarning: (message) => console.error(chalk.yellow(`Warning: ${message}`))
190
137
  });
191
138
  spinner.succeed(`Parsed ${title} (${result.retrievalMethod})`);
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
+
1
4
  type JsonObject = Record<string, any>;
2
5
  interface FetchOptions {
3
6
  timeoutMs?: number;
@@ -38,6 +41,70 @@ declare function parseSpecText(text: string, source?: string): JsonObject;
38
41
  /** Resolve a Swagger UI page or a direct OpenAPI JSON/YAML URL into a parsed spec. */
39
42
  declare function fetchOpenApiSpec(input: string, options?: FetchOptions): Promise<FetchResult>;
40
43
 
44
+ /** Arguments of the `swagger_digest` MCP tool (mirrors the zod schema in server.ts). */
45
+ interface McpDigestArgs {
46
+ /** Swagger UI page URL or direct OpenAPI spec URL. */
47
+ url: string;
48
+ /** Explicit spec URL; overrides discovery from the Swagger UI page. */
49
+ spec_url?: string;
50
+ /** Request headers (e.g. Authorization). Sent only to the origin of `url`. */
51
+ headers?: Record<string, string>;
52
+ /** Keep only operations carrying at least one of these tags. */
53
+ include_tags?: string[];
54
+ /** Drop operations carrying any of these tags. */
55
+ exclude_tags?: string[];
56
+ /** Keep only operations whose path starts with this prefix. */
57
+ path_prefix?: string;
58
+ /** Trim the digest until the approximate token estimate fits this budget. */
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;
65
+ /** Save the digest to a file instead of returning it inline. */
66
+ write_to_file?: {
67
+ directory: string;
68
+ filename: string;
69
+ };
70
+ }
71
+ /**
72
+ * Server-wide behavior switches. Tests pass these explicitly; the stdio entry
73
+ * point derives them from the environment.
74
+ */
75
+ interface DigestServerOptions {
76
+ /**
77
+ * Reject URLs resolving to loopback/private/metadata addresses. Defaults to
78
+ * true: an MCP client supplies URLs on the model's behalf, which is exactly
79
+ * the "server that accepts user-supplied URLs" case from the security notes.
80
+ * Users can opt out per deployment with SWAGGER_DIGEST_ALLOW_PRIVATE=1.
81
+ */
82
+ blockPrivateNetworks?: boolean;
83
+ }
84
+ /**
85
+ * Builds the request headers for a digest call: SWAGGER_DIGEST_HEADER
86
+ * environment entries first, then tool-call arguments (arguments win on
87
+ * conflicting names — same precedence as -H flags over the environment in the
88
+ * CLI). Header validation and origin scoping happen in headers.ts / fetcher.ts.
89
+ */
90
+ declare function buildRequestHeaders(args: Pick<McpDigestArgs, 'headers'>): Record<string, string>;
91
+ /**
92
+ * Runs one digest request and renders it as an MCP tool result. Expected
93
+ * failures (unreachable URLs, unparsable specs, invalid header arguments)
94
+ * become `isError` results carrying the message; unexpected errors propagate
95
+ * to the SDK, which converts them into protocol-level tool errors.
96
+ */
97
+ declare function runSwaggerDigest(args: McpDigestArgs, options?: DigestServerOptions): Promise<CallToolResult>;
98
+
99
+ /**
100
+ * Builds the MCP server exposing the `swagger_digest` tool.
101
+ *
102
+ * The instance is transport-agnostic: `src/mcp/index.ts` connects it to a
103
+ * StdioServerTransport, while tests connect it to an InMemoryTransport pair.
104
+ * No I/O happens at module scope or during construction.
105
+ */
106
+ declare function createMcpServer(options?: DigestServerOptions): McpServer;
107
+
41
108
  declare function toTypeScriptIdentifier(value: string): string;
42
109
 
43
110
  interface TransformOptions {
@@ -52,9 +119,24 @@ interface TransformOptions {
52
119
  * budget. The estimator is not tokenizer-accurate; treat the budget as a guide.
53
120
  */
54
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;
55
129
  /** Receives non-fatal notices, e.g. unknown tag names. */
56
130
  onWarning?: (message: string) => void;
57
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;
58
140
  /** Convert an OpenAPI 2.0, 3.0, or 3.1 document to a compact Markdown digest. */
59
141
  declare function transformOpenApiToMarkdown(spec: JsonObject, sourceUrl?: string, options?: TransformOptions): string;
60
142
 
@@ -91,5 +173,5 @@ declare function writeDigest(options: {
91
173
  filename: string;
92
174
  }): Promise<string>;
93
175
 
94
- export { defaultFilename, defaultOutputDirectory, estimateTokens, fetchOpenApiSpec, isOpenApiSpec, normalizeFilename, parseSpecText, resolveOutputDirectory, toTypeScriptIdentifier, transformOpenApiToMarkdown, writeDigest };
95
- export type { DigestTokenReport, FetchOptions, FetchResult, JsonObject, ModelTokenEstimate, TransformOptions };
176
+ export { DEFAULT_MAX_TOKENS, buildRequestHeaders, createMcpServer, defaultFilename, defaultOutputDirectory, estimateTokens, fetchOpenApiSpec, isOpenApiSpec, normalizeFilename, parseSpecText, resolveOutputDirectory, runSwaggerDigest, toTypeScriptIdentifier, transformOpenApiToMarkdown, writeDigest };
177
+ export type { DigestServerOptions, DigestTokenReport, FetchOptions, FetchResult, JsonObject, McpDigestArgs, ModelTokenEstimate, TransformOptions };
package/dist/index.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import {
2
+ buildRequestHeaders,
3
+ createMcpServer,
4
+ runSwaggerDigest
5
+ } from "./chunk-DJES77HX.js";
6
+ import {
7
+ DEFAULT_MAX_TOKENS,
2
8
  defaultFilename,
3
9
  defaultOutputDirectory,
4
10
  estimateTokens,
@@ -10,8 +16,11 @@ import {
10
16
  toTypeScriptIdentifier,
11
17
  transformOpenApiToMarkdown,
12
18
  writeDigest
13
- } from "./chunk-CBSDTIKN.js";
19
+ } from "./chunk-MXTHI25H.js";
14
20
  export {
21
+ DEFAULT_MAX_TOKENS,
22
+ buildRequestHeaders,
23
+ createMcpServer,
15
24
  defaultFilename,
16
25
  defaultOutputDirectory,
17
26
  estimateTokens,
@@ -20,6 +29,7 @@ export {
20
29
  normalizeFilename,
21
30
  parseSpecText,
22
31
  resolveOutputDirectory,
32
+ runSwaggerDigest,
23
33
  toTypeScriptIdentifier,
24
34
  transformOpenApiToMarkdown,
25
35
  writeDigest
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ createMcpServer
4
+ } from "../chunk-DJES77HX.js";
5
+ import "../chunk-MXTHI25H.js";
6
+
7
+ // src/mcp/index.ts
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ async function main() {
10
+ const server = createMcpServer();
11
+ await server.connect(new StdioServerTransport());
12
+ }
13
+ main().catch((error) => {
14
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
15
+ process.exitCode = 1;
16
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swagger-digest",
3
- "version": "0.1.2",
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",
@@ -12,7 +12,8 @@
12
12
  }
13
13
  },
14
14
  "bin": {
15
- "swagger-digest": "./dist/cli.js"
15
+ "swagger-digest": "./dist/cli.js",
16
+ "swagger-digest-mcp": "./dist/mcp/index.js"
16
17
  },
17
18
  "author": "VladLegkowski <VladLegkowski@users.noreply.github.com>",
18
19
  "repository": {
@@ -30,7 +31,7 @@
30
31
  "LICENSE"
31
32
  ],
32
33
  "scripts": {
33
- "build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
34
+ "build": "tsup src/index.ts src/cli.ts src/mcp/index.ts --format esm --dts --clean",
34
35
  "dev": "tsx src/cli.ts",
35
36
  "start": "node dist/cli.js",
36
37
  "typecheck": "tsc --noEmit",
@@ -46,11 +47,13 @@
46
47
  },
47
48
  "dependencies": {
48
49
  "@inquirer/prompts": "^7.3.2",
50
+ "@modelcontextprotocol/sdk": "^1.32.2",
49
51
  "chalk": "^5.4.1",
50
52
  "cheerio": "^1.0.0",
51
53
  "commander": "^13.1.0",
52
54
  "ora": "^8.2.0",
53
- "yaml": "^2.7.0"
55
+ "yaml": "^2.7.0",
56
+ "zod": "^4.6.7"
54
57
  },
55
58
  "devDependencies": {
56
59
  "@types/node": "^22.15.0",