swagger-digest 0.3.1 → 0.4.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
@@ -1,3 +1,5 @@
1
+ <img width="1376" height="768" alt="Image" src="https://github.com/user-attachments/assets/6a9e4eb3-fea8-47b3-9296-e57b4a702c7c" />
2
+
1
3
  # swagger-digest
2
4
 
3
5
  [![npm version](https://img.shields.io/npm/v/swagger-digest)](https://www.npmjs.com/package/swagger-digest)
@@ -22,7 +24,7 @@ swagger-digest solves this at two levels: a **digest** (compact, complete, token
22
24
 
23
25
  ### Option 1: MCP server — recommended for AI coding agents
24
26
 
25
- Run it as an [MCP](https://modelcontextprotocol.io) server and your agent explores APIs on its own: it calls the `swagger_digest` tool with a Swagger UI page or spec URL and receives a compact digest directly in its context — discovery, filtering and token budgets handled for it.
27
+ Run it as an [MCP](https://modelcontextprotocol.io) server and your agent explores APIs on its own: it calls the `swagger_digest` tool with a Swagger UI page or spec URL and receives a compact digest directly in its context — discovery, filtering and token budgets handled for it. Optionally, it can also execute requests against a trusted API with the `api_call` tool (see [Live API execution](#live-api-execution-api_call)).
26
28
 
27
29
  - **On-demand:** the agent fetches API context only when the task needs it.
28
30
  - **Scoped:** filter to specific tags or paths so only relevant operations enter context.
@@ -255,6 +257,57 @@ The `swagger_digest` tool:
255
257
  | `auto_trim` | boolean | Default-cap digests without `max_tokens` (default true) |
256
258
  | `write_to_file` | object | `{ directory, filename }` — save instead of returning inline |
257
259
 
260
+ ### Live API execution (`api_call`)
261
+
262
+ Beyond reading documentation, the server can execute requests against **one trusted API origin**, turning the workflow from _read → implement → hope_ into _read → call → inspect → correct_. Non-2xx responses are returned as results (not errors) so error bodies can be inspected, and large response bodies are trimmed to a token budget.
263
+
264
+ It is inactive until configured. Set the trusted origin when starting the server:
265
+
266
+ ```json
267
+ {
268
+ "mcpServers": {
269
+ "swagger-digest": {
270
+ "command": ["npx", "-y", "swagger-digest-mcp"],
271
+ "environment": {
272
+ "SWAGGER_DIGEST_API_BASE_URL": "https://staging.example.com",
273
+ "SWAGGER_DIGEST_API_HEADER": "Authorization: Bearer sk-…",
274
+ "SWAGGER_DIGEST_API_METHODS": "get"
275
+ }
276
+ }
277
+ }
278
+ }
279
+ ```
280
+
281
+ The `api_call` tool:
282
+
283
+ | Argument | Type | Description |
284
+ | ------------ | ------ | ----------------------------------------------------------------- |
285
+ | `path` | string | API path starting with `/`, resolved against the trusted base URL |
286
+ | `url` | string | Absolute URL; its origin must equal the trusted base URL origin |
287
+ | `method` | string | HTTP method (default `get`; writes need explicit opt-in) |
288
+ | `query` | object | Query parameters; array values become repeated keys |
289
+ | `headers` | object | Extra headers; credential-bearing names are rejected |
290
+ | `body` | any | JSON-serializable body (POST/PUT/PATCH/DELETE only) |
291
+ | `max_tokens` | number | Trim large response bodies to an approximate token budget |
292
+
293
+ Either `path` or `url` is required. Provide one, not both.
294
+
295
+ Security model — enforced server-side, not by client approval:
296
+
297
+ - **Single trusted origin.** Requests resolve only against `SWAGGER_DIGEST_API_BASE_URL`. Server URLs declared inside an OpenAPI spec are **never trusted implicitly** — a malicious spec could point at an attacker-controlled host, so absolute `url` arguments must match the configured origin and are refused otherwise.
298
+ - **GET only by default.** Write methods require `SWAGGER_DIGEST_API_METHODS` (e.g. `get,post`); every method name is validated.
299
+ - **Credentials never enter the conversation.** Authentication is injected server-side from `SWAGGER_DIGEST_API_HEADER`; tool arguments containing `authorization`, `cookie`, `x-api-key` and similar are rejected outright. Response bodies are scanned and any echoed credential is redacted.
300
+ - **SSRF guard.** Private/loopback/metadata addresses are rejected per hop, including across redirects (same `SWAGGER_DIGEST_ALLOW_PRIVATE=1` escape hatch as the digest tool, for internal corporate APIs).
301
+ - **Bounded.** Mandatory timeout (default 15 s, hard max 60 s), 1 MB request/response body cap (configurable), token-capped output.
302
+
303
+ `api_call` environment variables:
304
+
305
+ - `SWAGGER_DIGEST_API_BASE_URL` — the single trusted API origin (required; without it the tool explains how to configure itself).
306
+ - `SWAGGER_DIGEST_API_HEADER` — newline-separated `Name: value` authentication headers (same format as `SWAGGER_DIGEST_HEADER`).
307
+ - `SWAGGER_DIGEST_API_METHODS` — comma-separated enabled methods; default `get`.
308
+ - `SWAGGER_DIGEST_API_TIMEOUT_MS` — request timeout (default 15000, hard max 60000).
309
+ - `SWAGGER_DIGEST_API_MAX_BYTES` — request/response body cap in bytes (default 1048576).
310
+
258
311
  MCP environment variables:
259
312
 
260
313
  - `SWAGGER_DIGEST_HEADER` — newline-separated `Name: value` pairs, merged with (and overridden by) the call's `headers`. Keeps secrets out of the conversation.
@@ -265,15 +265,19 @@ async function follow(url, defaultHeaders, userHeaders, options, maxBytes, redir
265
265
  options.onCrossOriginHeadersDropped?.(target.origin);
266
266
  }
267
267
  const requestHeaders = sameOrigin ? { ...defaultHeaders, ...userHeaders } : defaultHeaders;
268
+ const method = (options.method ?? "GET").toUpperCase();
269
+ const carriesBody = options.body !== void 0 && method !== "GET" && method !== "HEAD" && method !== "OPTIONS" && method !== "TRACE";
268
270
  const controller = new AbortController();
269
271
  const timeout = AbortSignal.timeout(options.timeoutMs);
270
272
  timeout.addEventListener("abort", () => controller.abort(), { once: true });
271
273
  let response;
272
274
  try {
273
275
  response = await fetch(target, {
276
+ method,
274
277
  headers: requestHeaders,
275
278
  redirect: "manual",
276
- signal: controller.signal
279
+ signal: controller.signal,
280
+ ...carriesBody ? { body: options.body } : {}
277
281
  });
278
282
  } catch (error) {
279
283
  if (error instanceof FetchError) throw error;
@@ -293,9 +297,11 @@ async function follow(url, defaultHeaders, userHeaders, options, maxBytes, redir
293
297
  } catch {
294
298
  }
295
299
  const next = new URL(response.headers.get("location") ?? "", target);
296
- return follow(next.toString(), defaultHeaders, userHeaders, options, maxBytes, redirectsLeft - 1);
300
+ const downgraded = response.status === 301 || response.status === 302 || response.status === 303;
301
+ const nextOptions = downgraded ? { ...options, method: "GET", body: void 0 } : { ...options };
302
+ return follow(next.toString(), defaultHeaders, userHeaders, nextOptions, maxBytes, redirectsLeft - 1);
297
303
  }
298
- if (!response.ok) {
304
+ if (!response.ok && options.throwOnHttpError !== false) {
299
305
  await discardBodySafely(response);
300
306
  throw new FetchHttpError(`HTTP ${response.status} ${response.statusText} for ${url}`, response.status);
301
307
  }
@@ -314,6 +320,7 @@ async function follow(url, defaultHeaders, userHeaders, options, maxBytes, redir
314
320
  text,
315
321
  contentType: response.headers.get("content-type") ?? "",
316
322
  status: response.status,
323
+ statusText: response.statusText ?? "",
317
324
  url: target.toString()
318
325
  };
319
326
  }
@@ -2188,6 +2195,21 @@ function parseHeaderPairs(raw) {
2188
2195
  }
2189
2196
  return result;
2190
2197
  }
2198
+ var SENSITIVE_HEADER_NAMES = /* @__PURE__ */ new Set([
2199
+ "authorization",
2200
+ "proxy-authorization",
2201
+ "cookie",
2202
+ "set-cookie",
2203
+ "x-api-key",
2204
+ "api-key",
2205
+ "apikey",
2206
+ "x-auth-token",
2207
+ "x-access-token",
2208
+ "x-session-token"
2209
+ ]);
2210
+ function isSensitiveHeaderName(name) {
2211
+ return SENSITIVE_HEADER_NAMES.has(name.trim().toLowerCase());
2212
+ }
2191
2213
  function mergeHeaders(...sources) {
2192
2214
  const result = {};
2193
2215
  for (const source of sources) {
@@ -2234,6 +2256,7 @@ async function writeDigest(options) {
2234
2256
 
2235
2257
  export {
2236
2258
  VERSION,
2259
+ fetchWithLimits,
2237
2260
  toTypeScriptIdentifier,
2238
2261
  isOpenApiSpec,
2239
2262
  parseSpecText,
@@ -2243,6 +2266,7 @@ export {
2243
2266
  transformOpenApiToMarkdown,
2244
2267
  parseHeaderArgument,
2245
2268
  parseHeaderPairs,
2269
+ isSensitiveHeaderName,
2246
2270
  mergeHeaders,
2247
2271
  defaultOutputDirectory,
2248
2272
  defaultFilename,
@@ -0,0 +1,392 @@
1
+ import {
2
+ VERSION,
3
+ estimateTokens,
4
+ fetchOpenApiSpec,
5
+ fetchWithLimits,
6
+ isSensitiveHeaderName,
7
+ mergeHeaders,
8
+ parseHeaderPairs,
9
+ transformOpenApiToMarkdown,
10
+ writeDigest
11
+ } from "./chunk-2MQ33ITZ.js";
12
+
13
+ // src/mcp/digest.ts
14
+ var TOKEN_HINT_THRESHOLD = 8e4;
15
+ function textContent(text) {
16
+ return [{ type: "text", text }];
17
+ }
18
+ function asError(message) {
19
+ return { content: textContent(message), isError: true };
20
+ }
21
+ function isHttpUrl(value) {
22
+ try {
23
+ const url = new URL(value);
24
+ return url.protocol === "http:" || url.protocol === "https:";
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+ function countDigestOperations(markdown) {
30
+ return (markdown.match(/^\| \*\*(?:GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE)\*\* \|/gm) ?? []).length;
31
+ }
32
+ function countDigestModels(markdown) {
33
+ return (markdown.match(/^export (?:interface|type) \w+/gm) ?? []).length;
34
+ }
35
+ function buildRequestHeaders(args) {
36
+ const fromEnv = process.env.SWAGGER_DIGEST_HEADER ? parseHeaderPairs(process.env.SWAGGER_DIGEST_HEADER) : {};
37
+ return mergeHeaders(fromEnv, args.headers);
38
+ }
39
+ async function runSwaggerDigest(args, options = {}) {
40
+ if (!isHttpUrl(args.url)) {
41
+ return asError(`Invalid url "${args.url}": use an http:// or https:// URL.`);
42
+ }
43
+ if (args.spec_url !== void 0 && !isHttpUrl(args.spec_url)) {
44
+ return asError(`Invalid spec_url "${args.spec_url}": use an http:// or https:// URL.`);
45
+ }
46
+ if (args.max_tokens !== void 0 && (!Number.isInteger(args.max_tokens) || args.max_tokens <= 0)) {
47
+ return asError(`Invalid max_tokens ${args.max_tokens}: use a positive integer.`);
48
+ }
49
+ const blockPrivateNetworks = options.blockPrivateNetworks ?? process.env.SWAGGER_DIGEST_ALLOW_PRIVATE !== "1";
50
+ const warnings = [];
51
+ const warn = (message) => {
52
+ warnings.push(message);
53
+ };
54
+ let fetchResult;
55
+ try {
56
+ const fetchOptions = {
57
+ useBrowserFallback: false,
58
+ maxBytes: 1e7,
59
+ headers: buildRequestHeaders(args),
60
+ specUrl: args.spec_url,
61
+ blockPrivateNetworks,
62
+ warn
63
+ };
64
+ fetchResult = await fetchOpenApiSpec(args.url, fetchOptions);
65
+ } catch (error) {
66
+ const message = error instanceof Error ? error.message : String(error);
67
+ return asError(`Could not extract an OpenAPI definition from ${args.url}: ${message}`);
68
+ }
69
+ const markdown = transformOpenApiToMarkdown(fetchResult.spec, fetchResult.sourceUrl, {
70
+ includeTags: args.include_tags,
71
+ excludeTags: args.exclude_tags,
72
+ pathPrefix: args.path_prefix,
73
+ maxTokens: args.max_tokens,
74
+ autoTrim: args.auto_trim !== false,
75
+ onWarning: warn
76
+ });
77
+ const operationCount = countDigestOperations(markdown);
78
+ const modelCount = countDigestModels(markdown);
79
+ const tokens = estimateTokens(markdown).models.claude.tokens;
80
+ const metadata = [
81
+ `source: ${fetchResult.sourceUrl} (via ${fetchResult.retrievalMethod})`,
82
+ `${operationCount} operation${operationCount === 1 ? "" : "s"}`,
83
+ `${modelCount} data model${modelCount === 1 ? "" : "s"}`,
84
+ `~${tokens.toLocaleString("en-US")} tokens estimated`
85
+ ].join(" \xB7 ");
86
+ const warningBlock = warnings.length > 0 ? `
87
+
88
+ _Warnings:_
89
+ ${warnings.map((w) => `- ${w}`).join("\n")}` : "";
90
+ 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._" : "";
91
+ if (args.write_to_file) {
92
+ try {
93
+ const outputPath = await writeDigest({
94
+ markdown,
95
+ directory: args.write_to_file.directory,
96
+ filename: args.write_to_file.filename
97
+ });
98
+ return { content: textContent(`Digest written to ${outputPath}
99
+
100
+ _${metadata}_${warningBlock}`) };
101
+ } catch (error) {
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ return asError(`Could not write digest: ${message}`);
104
+ }
105
+ }
106
+ return { content: textContent(`${markdown}
107
+
108
+ ---
109
+ _Digest metadata: ${metadata}_${warningBlock}${budgetHint}`) };
110
+ }
111
+
112
+ // src/mcp/server.ts
113
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
114
+ import { z } from "zod";
115
+
116
+ // src/mcp/api-call.ts
117
+ var DEFAULT_TIMEOUT_MS = 15e3;
118
+ var HARD_MAX_TIMEOUT_MS = 6e4;
119
+ var DEFAULT_MAX_BYTES = 1024 * 1024;
120
+ var ALL_METHODS = /* @__PURE__ */ new Set(["get", "head", "post", "put", "patch", "delete"]);
121
+ var BODY_METHODS = /* @__PURE__ */ new Set(["post", "put", "patch", "delete"]);
122
+ function parsePositiveInt(raw) {
123
+ if (raw === void 0 || raw.trim() === "") return void 0;
124
+ const value = Number(raw);
125
+ return Number.isFinite(value) && value > 0 ? value : void 0;
126
+ }
127
+ function parseMethodsEnv(raw) {
128
+ if (raw === void 0 || raw.trim() === "") return void 0;
129
+ return raw.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
130
+ }
131
+ function isHttpUrl2(value) {
132
+ try {
133
+ const url = new URL(value);
134
+ return url.protocol === "http:" || url.protocol === "https:";
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+ function resolveApiCallConfig(options = {}, env = process.env) {
140
+ const baseUrlRaw = options.baseUrl ?? env.SWAGGER_DIGEST_API_BASE_URL;
141
+ if (baseUrlRaw === void 0 || baseUrlRaw.trim() === "") {
142
+ return {
143
+ error: 'api_call is not configured. Set SWAGGER_DIGEST_API_BASE_URL to the trusted API origin (for example "https://staging.example.com") when starting the MCP server. Requests are only ever sent to this origin \u2014 spec-declared server URLs are not trusted implicitly.'
144
+ };
145
+ }
146
+ let baseUrl;
147
+ try {
148
+ baseUrl = new URL(baseUrlRaw);
149
+ } catch {
150
+ return {
151
+ error: `Invalid SWAGGER_DIGEST_API_BASE_URL "${baseUrlRaw}": use an absolute http:// or https:// URL.`
152
+ };
153
+ }
154
+ if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
155
+ return { error: `Invalid SWAGGER_DIGEST_API_BASE_URL "${baseUrlRaw}": use an http:// or https:// URL.` };
156
+ }
157
+ const methodList = options.methods ?? parseMethodsEnv(env.SWAGGER_DIGEST_API_METHODS) ?? ["get"];
158
+ for (const method of methodList) {
159
+ if (!ALL_METHODS.has(method)) {
160
+ return {
161
+ error: `Unknown method "${method}" in the api_call method list. Allowed: ${[...ALL_METHODS].join(", ")}.`
162
+ };
163
+ }
164
+ }
165
+ const methods = new Set(methodList);
166
+ let authHeaders = {};
167
+ if (options.authHeaders !== void 0) {
168
+ authHeaders = options.authHeaders;
169
+ } else if (env.SWAGGER_DIGEST_API_HEADER !== void 0) {
170
+ try {
171
+ authHeaders = parseHeaderPairs(env.SWAGGER_DIGEST_API_HEADER);
172
+ } catch (error) {
173
+ return {
174
+ error: `Invalid SWAGGER_DIGEST_API_HEADER: ${error instanceof Error ? error.message : String(error)}`
175
+ };
176
+ }
177
+ }
178
+ const requestedTimeout = options.timeoutMs ?? parsePositiveInt(env.SWAGGER_DIGEST_API_TIMEOUT_MS) ?? DEFAULT_TIMEOUT_MS;
179
+ const timeoutMs = Math.min(Math.max(requestedTimeout, 1), HARD_MAX_TIMEOUT_MS);
180
+ const maxBytes = options.maxBytes ?? parsePositiveInt(env.SWAGGER_DIGEST_API_MAX_BYTES) ?? DEFAULT_MAX_BYTES;
181
+ const blockPrivateNetworks = options.blockPrivateNetworks ?? env.SWAGGER_DIGEST_ALLOW_PRIVATE !== "1";
182
+ return { config: { baseUrl, methods, authHeaders, timeoutMs, maxBytes, blockPrivateNetworks } };
183
+ }
184
+ function textContent2(text) {
185
+ return [{ type: "text", text }];
186
+ }
187
+ function asError2(message) {
188
+ return { content: textContent2(message), isError: true };
189
+ }
190
+ function estimateResponseTokens(text) {
191
+ const heuristic = estimateTokens(text).models.claude.tokens;
192
+ return Math.max(heuristic, Math.ceil(text.length / 4));
193
+ }
194
+ function trimToTokenBudget(text, maxTokens) {
195
+ const estimated = estimateResponseTokens(text);
196
+ if (estimated <= maxTokens) return { text, trimmed: false };
197
+ const marker = "\n\u2026[response truncated to fit the max_tokens budget]";
198
+ const keep = Math.max(0, Math.floor(text.length * maxTokens / estimated) - marker.length);
199
+ return { text: text.slice(0, keep) + marker, trimmed: true };
200
+ }
201
+ function redactSecrets(text, secrets) {
202
+ let result = text;
203
+ for (const secret of secrets) {
204
+ if (secret.length >= 8) result = result.split(secret).join("[REDACTED]");
205
+ }
206
+ return result;
207
+ }
208
+ async function runApiCall(args, options = {}) {
209
+ const { config, error } = resolveApiCallConfig(options);
210
+ if (error !== void 0 || config === void 0) return asError2(error ?? "api_call configuration error.");
211
+ if (args.max_tokens !== void 0 && (!Number.isInteger(args.max_tokens) || args.max_tokens <= 0)) {
212
+ return asError2(`Invalid max_tokens ${args.max_tokens}: use a positive integer.`);
213
+ }
214
+ const method = (args.method ?? "get").trim().toLowerCase();
215
+ if (!ALL_METHODS.has(method)) {
216
+ return asError2(
217
+ `Unknown method "${method}". Allowed: ${[...ALL_METHODS].join(", ")}. Enabled methods are controlled by SWAGGER_DIGEST_API_METHODS (default: get).`
218
+ );
219
+ }
220
+ if (!config.methods.has(method)) {
221
+ return asError2(
222
+ `Method "${method}" is not enabled. Enabled methods: ${[...config.methods].sort().join(", ")}. Write methods require an explicit opt-in: set SWAGGER_DIGEST_API_METHODS (e.g. "get,post") when starting the server.`
223
+ );
224
+ }
225
+ let target;
226
+ if (args.url !== void 0 && args.path !== void 0) {
227
+ return asError2("Provide either `url` or `path`, not both.");
228
+ }
229
+ if (args.url !== void 0) {
230
+ if (!isHttpUrl2(args.url)) {
231
+ return asError2(`Invalid url "${args.url}": use an http:// or https:// URL.`);
232
+ }
233
+ target = new URL(args.url);
234
+ } else if (typeof args.path === "string") {
235
+ if (!args.path.startsWith("/")) {
236
+ return asError2(
237
+ `Invalid path "${args.path}": paths must start with "/" and are resolved against the configured base URL.`
238
+ );
239
+ }
240
+ target = new URL(args.path, config.baseUrl);
241
+ } else {
242
+ return asError2(
243
+ 'Provide a target: `path` (e.g. "/v1/refunds", resolved against SWAGGER_DIGEST_API_BASE_URL) or an absolute `url` on the configured trusted origin.'
244
+ );
245
+ }
246
+ if (target.origin !== config.baseUrl.origin) {
247
+ return asError2(
248
+ `Refusing to call ${target.origin}: api_call only sends requests to the configured trusted origin ${config.baseUrl.origin} (SWAGGER_DIGEST_API_BASE_URL). Origins declared inside an OpenAPI specification are never trusted implicitly \u2014 a malicious spec could otherwise redirect credentials.`
249
+ );
250
+ }
251
+ for (const [key, value] of Object.entries(args.query ?? {})) {
252
+ if (Array.isArray(value)) {
253
+ for (const item of value) target.searchParams.append(key, String(item));
254
+ } else {
255
+ target.searchParams.set(key, String(value));
256
+ }
257
+ }
258
+ let bodyText;
259
+ if (args.body !== void 0) {
260
+ if (!BODY_METHODS.has(method)) {
261
+ return asError2(`Method "${method}" cannot carry a request body.`);
262
+ }
263
+ try {
264
+ bodyText = JSON.stringify(args.body);
265
+ } catch {
266
+ return asError2("Invalid body: it must be JSON-serializable.");
267
+ }
268
+ if (bodyText.length > 0 && Buffer.byteLength(bodyText) > config.maxBytes) {
269
+ return asError2(
270
+ `Request body is ${Buffer.byteLength(bodyText)} bytes, exceeding the ${config.maxBytes} byte limit (SWAGGER_DIGEST_API_MAX_BYTES).`
271
+ );
272
+ }
273
+ }
274
+ for (const name of Object.keys(args.headers ?? {})) {
275
+ if (isSensitiveHeaderName(name)) {
276
+ return asError2(
277
+ `Refusing to send "${name}" from tool arguments: authentication headers are injected server-side via SWAGGER_DIGEST_API_HEADER so credentials never enter tool-call transcripts.`
278
+ );
279
+ }
280
+ }
281
+ const requestHeaders = mergeHeaders(config.authHeaders, args.headers);
282
+ if (bodyText !== void 0) {
283
+ requestHeaders["content-type"] = args.headers?.["content-type"] ?? args.headers?.["Content-Type"] ?? "application/json";
284
+ }
285
+ const warnings = [];
286
+ let result;
287
+ try {
288
+ result = await fetchWithLimits(target.toString(), {
289
+ timeoutMs: config.timeoutMs,
290
+ maxBytes: config.maxBytes,
291
+ blockPrivateNetworks: config.blockPrivateNetworks,
292
+ headers: requestHeaders,
293
+ allowedHeaderOrigin: config.baseUrl.origin,
294
+ onCrossOriginHeadersDropped: (origin) => warnings.push(`Request redirected cross-origin to ${origin}; authentication headers were withheld.`),
295
+ method,
296
+ body: bodyText,
297
+ throwOnHttpError: false
298
+ });
299
+ } catch (requestError) {
300
+ const message = requestError instanceof Error ? requestError.message : String(requestError);
301
+ return asError2(`api_call failed: ${message}`);
302
+ }
303
+ let responseBody = redactSecrets(result.text, Object.values(config.authHeaders));
304
+ let wasTrimmed = false;
305
+ if (args.max_tokens !== void 0) {
306
+ const trimmedResult = trimToTokenBudget(responseBody, args.max_tokens);
307
+ responseBody = trimmedResult.text;
308
+ wasTrimmed = trimmedResult.trimmed;
309
+ }
310
+ const tokens = estimateResponseTokens(responseBody);
311
+ const bytes = Buffer.byteLength(result.text);
312
+ const head = [`status: ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`];
313
+ head.push(`url: ${result.url}`);
314
+ head.push(`content-type: ${result.contentType || "unknown"}`);
315
+ const nonOkNote = result.status >= 400 ? `
316
+
317
+ _Non-OK response returned as a result (not an error) so the body can be inspected._` : "";
318
+ const warningBlock = warnings.length > 0 ? `
319
+
320
+ _Warnings:_
321
+ ${warnings.map((w) => `- ${w}`).join("\n")}` : "";
322
+ const metadata = `${bytes.toLocaleString("en-US")} bytes \xB7 ~${tokens.toLocaleString("en-US")} tokens estimated` + (wasTrimmed ? " \xB7 trimmed to max_tokens" : "");
323
+ return {
324
+ content: textContent2(
325
+ `${head.join("\n")}
326
+
327
+ ${responseBody}${nonOkNote}
328
+
329
+ ---
330
+ _api_call metadata: ${metadata}_${warningBlock}`
331
+ )
332
+ };
333
+ }
334
+
335
+ // src/mcp/server.ts
336
+ function createMcpServer(options = {}) {
337
+ const server = new McpServer({ name: "swagger-digest", version: VERSION });
338
+ server.registerTool(
339
+ "swagger_digest",
340
+ {
341
+ title: "Generate API digest",
342
+ 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.",
343
+ inputSchema: {
344
+ url: z.string().describe("Swagger UI page URL or direct OpenAPI spec URL (http/https)"),
345
+ spec_url: z.string().optional().describe("Explicit OpenAPI spec URL; overrides discovery from the Swagger UI page"),
346
+ headers: z.record(z.string(), z.string()).optional().describe(
347
+ "Request headers (e.g. Authorization or Cookie). Sent ONLY to the origin of url; cross-origin requests never receive them. Values are not logged."
348
+ ),
349
+ include_tags: z.array(z.string()).optional().describe("Keep only operations carrying at least one of these tags"),
350
+ exclude_tags: z.array(z.string()).optional().describe("Drop operations carrying any of these tags"),
351
+ path_prefix: z.string().optional().describe("Keep only operations whose path starts with this prefix"),
352
+ max_tokens: z.number().int().positive().optional().describe(
353
+ "Trim the digest until an approximate token estimate fits this budget (heuristic, not tokenizer-accurate)"
354
+ ),
355
+ auto_trim: z.boolean().optional().describe(
356
+ "Auto-trim digests exceeding the 250,000-token default budget when max_tokens is not given. Defaults to true."
357
+ ),
358
+ write_to_file: z.object({ directory: z.string(), filename: z.string() }).optional().describe("Save the digest to a file instead of returning it inline")
359
+ }
360
+ },
361
+ (args) => runSwaggerDigest(args, options)
362
+ );
363
+ server.registerTool(
364
+ "api_call",
365
+ {
366
+ title: "Execute an API request",
367
+ description: "Execute an HTTP request against the trusted API origin configured via SWAGGER_DIGEST_API_BASE_URL (set when the MCP server starts; the tool returns a clear configuration error when it is unset). Use it after swagger_digest to verify how the real API behaves: send GET (default) or, when write methods were enabled via SWAGGER_DIGEST_API_METHODS, POST/PUT/PATCH/DELETE. Provide either `path` (resolved against the trusted origin) or an absolute `url` on that same origin \u2014 other origins are refused, and server URLs declared inside an OpenAPI spec are never trusted implicitly. Authentication headers are injected server-side; credential headers in arguments are rejected. Non-2xx responses are returned as results (not errors) so error bodies can be inspected. Responses are size- and token-capped.",
368
+ inputSchema: {
369
+ url: z.string().optional().describe("Absolute http(s) URL whose origin must equal the configured trusted API origin"),
370
+ path: z.string().optional().describe('API path starting with "/", e.g. "/v1/refunds" \u2014 resolved against SWAGGER_DIGEST_API_BASE_URL'),
371
+ method: z.string().optional().describe('HTTP method (default "get"). Write methods require SWAGGER_DIGEST_API_METHODS opt-in'),
372
+ query: z.record(
373
+ z.string(),
374
+ z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number(), z.boolean()]))])
375
+ ).optional().describe("Query parameters; array values become repeated keys"),
376
+ headers: z.record(z.string(), z.string()).optional().describe(
377
+ "Extra request headers. Credential-bearing names (authorization, cookie, x-api-key, \u2026) are rejected; authentication comes from SWAGGER_DIGEST_API_HEADER"
378
+ ),
379
+ body: z.unknown().optional().describe("JSON-serializable request body (POST/PUT/PATCH/DELETE only)"),
380
+ max_tokens: z.number().int().positive().optional().describe("Trim large response bodies to approximately this token budget")
381
+ }
382
+ },
383
+ (args) => runApiCall(args, options.apiCall)
384
+ );
385
+ return server;
386
+ }
387
+
388
+ export {
389
+ buildRequestHeaders,
390
+ runSwaggerDigest,
391
+ createMcpServer
392
+ };
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  parseHeaderPairs,
11
11
  transformOpenApiToMarkdown,
12
12
  writeDigest
13
- } from "./chunk-MXTHI25H.js";
13
+ } from "./chunk-2MQ33ITZ.js";
14
14
 
15
15
  // src/cli.ts
16
16
  import { input } from "@inquirer/prompts";
package/dist/index.d.ts CHANGED
@@ -97,13 +97,40 @@ declare function buildRequestHeaders(args: Pick<McpDigestArgs, 'headers'>): Reco
97
97
  declare function runSwaggerDigest(args: McpDigestArgs, options?: DigestServerOptions): Promise<CallToolResult>;
98
98
 
99
99
  /**
100
- * Builds the MCP server exposing the `swagger_digest` tool.
100
+ * Server-wide configuration for `api_call`. Tests pass these explicitly; the
101
+ * stdio entry point derives them from the environment (see resolveApiCallConfig).
102
+ */
103
+ interface ApiCallServerOptions {
104
+ /** Reject URLs resolving to loopback/private/metadata addresses. Defaults to true. */
105
+ blockPrivateNetworks?: boolean;
106
+ /** Trusted API origin. Overrides SWAGGER_DIGEST_API_BASE_URL. */
107
+ baseUrl?: string;
108
+ /** Enabled HTTP methods (lowercase). Overrides SWAGGER_DIGEST_API_METHODS. */
109
+ methods?: string[];
110
+ /** Server-injected authentication headers. Overrides SWAGGER_DIGEST_API_HEADER. */
111
+ authHeaders?: Record<string, string>;
112
+ /** Request timeout in ms. Overrides SWAGGER_DIGEST_API_TIMEOUT_MS (hard max 60 s). */
113
+ timeoutMs?: number;
114
+ /** Maximum response/request body size in bytes. Overrides SWAGGER_DIGEST_API_MAX_BYTES. */
115
+ maxBytes?: number;
116
+ }
117
+
118
+ /** Options for the whole MCP server: shared behavior plus per-tool overrides. */
119
+ interface SwaggerDigestMcpOptions extends DigestServerOptions {
120
+ /**
121
+ * Configuration overrides for the `api_call` tool. Tests inject these;
122
+ * production derives them from SWAGGER_DIGEST_API_* environment variables.
123
+ */
124
+ apiCall?: ApiCallServerOptions;
125
+ }
126
+ /**
127
+ * Builds the MCP server exposing the `swagger_digest` and `api_call` tools.
101
128
  *
102
129
  * The instance is transport-agnostic: `src/mcp/index.ts` connects it to a
103
130
  * StdioServerTransport, while tests connect it to an InMemoryTransport pair.
104
131
  * No I/O happens at module scope or during construction.
105
132
  */
106
- declare function createMcpServer(options?: DigestServerOptions): McpServer;
133
+ declare function createMcpServer(options?: SwaggerDigestMcpOptions): McpServer;
107
134
 
108
135
  declare function toTypeScriptIdentifier(value: string): string;
109
136
 
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  buildRequestHeaders,
3
3
  createMcpServer,
4
4
  runSwaggerDigest
5
- } from "./chunk-DJES77HX.js";
5
+ } from "./chunk-UBR7FEJX.js";
6
6
  import {
7
7
  DEFAULT_MAX_TOKENS,
8
8
  defaultFilename,
@@ -16,7 +16,7 @@ import {
16
16
  toTypeScriptIdentifier,
17
17
  transformOpenApiToMarkdown,
18
18
  writeDigest
19
- } from "./chunk-MXTHI25H.js";
19
+ } from "./chunk-2MQ33ITZ.js";
20
20
  export {
21
21
  DEFAULT_MAX_TOKENS,
22
22
  buildRequestHeaders,
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-DJES77HX.js";
5
- import "../chunk-MXTHI25H.js";
4
+ } from "../chunk-UBR7FEJX.js";
5
+ import "../chunk-2MQ33ITZ.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.3.1",
3
+ "version": "0.4.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",
@@ -1,147 +0,0 @@
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
- };