@sezzlee/openapi-mcp 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaan Akın
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # @sezzlee/openapi-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ MCP server that exposes an OpenAPI (Swagger 2.0 / OpenAPI 3.0–3.2) document as sezzlee's
4
+ search-first tool catalog — `search_tools`, `load_tool`, `invoke_tool` — over a **remote**
5
+ backend, calling it with `fetch`.
6
+
7
+ > Status: `0.2.0`, alpha. Install with `npm install -g @sezzlee/openapi-mcp` or run it with
8
+ > `npx -y @sezzlee/openapi-mcp`; the binary is `sezzlee-openapi`.
9
+
10
+ ## How this differs from the embedded SDKs
11
+
12
+ `Sezzlee.AspNetCore` and the NestJS SDK sit **inside** your backend process: they discover
13
+ endpoints from your own controllers/routes and replay each MCP call through your existing
14
+ pipeline, so your authentication and authorization run exactly as they do today.
15
+
16
+ `openapi-mcp` is a separate service. It has no access to a backend's process or pipeline — it
17
+ reads the backend's **OpenAPI document** to learn what operations exist ([the ingestion
18
+ rules](../../http/spec/openapi-ingestion.md)), and calls the backend over the network for every
19
+ invocation, attaching a credential it resolves itself ([how a credential is chosen and
20
+ written](../../http/spec/credentials.md)). This is the only path for a backend that has no
21
+ embedded SDK integrated into it.
22
+
23
+ ## Quick start
24
+
25
+ The server reads its configuration from the file named by `SEZZLEE_OPENAPI_CONFIG`. Build the
26
+ package first (this repository never runs `@sezzlee/core`'s consumers against a stale `dist`):
27
+
28
+ ```bash
29
+ pnpm turbo run build --filter=@sezzlee/openapi-mcp
30
+ ```
31
+
32
+ A minimal config — a local document, opt-in selection so at least one operation is exposed,
33
+ default `stdio` transport:
34
+
35
+ ```json
36
+ {
37
+ "source": "./openapi.json",
38
+ "selection": { "default": "include" }
39
+ }
40
+ ```
41
+
42
+ ```bash
43
+ SEZZLEE_OPENAPI_CONFIG=/absolute/path/to/config.json node packages/servers/openapi-mcp/dist/cli.js
44
+ ```
45
+
46
+ `source` is either a path (resolved relative to the config file's own directory) or an
47
+ `http(s)://` URL. On startup the server ingests the document, builds the catalog, prints a
48
+ one-line-per-diagnostic-code summary to stderr, and then serves `stdio` or listens for
49
+ `streamable HTTP`, depending on `transport.kind`.
50
+
51
+ A config that fails validation, or that names an environment variable that is not set, stops the
52
+ process with a message on stderr and exit code `2`. A document that ingests with a fatal
53
+ diagnostic, or a catalog with a fatal diagnostic (`name_collision`, `invalid_name`,
54
+ `ambiguous_selection`, …), stops it with exit code `1`.
55
+
56
+ ## Configuration
57
+
58
+ All keys below are read from the `SEZZLEE_OPENAPI_CONFIG` JSON file and validated with a `zod`
59
+ schema (`src/platform/config.ts`) that rejects unknown keys.
60
+
61
+ | Key | Type | Default | Notes |
62
+ | -------------------------------- | ------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
63
+ | `transport.kind` | `"stdio"` \| `"http"` | `"stdio"` | — |
64
+ | `transport.host` | string (`http` only) | `"127.0.0.1"` | Must stay loopback (`127.0.0.1`, `::1`, `localhost`) unless `tokenExchange` is configured — see below |
65
+ | `transport.port` | int, 0–65535 (`http` only) | `8787` | — |
66
+ | `transport.path` | string starting with `/` (`http` only) | `"/mcp"` | — |
67
+ | `transport.resource` | url (`http` only) | required | The OAuth protected-resource identifier advertised at `/.well-known/oauth-protected-resource` |
68
+ | `transport.authorizationServers` | array of url, min 1 (`http` only) | required | — |
69
+ | `transport.allowedHostnames` | array of string (`http` only) | `[]` | Extra `Host` header values accepted, on top of the localhost defaults |
70
+ | `tokenExchange` | object | not configured | RFC 8693 token exchange; required for an `http` transport to authenticate a caller (see [Rules](#rules)) |
71
+ | `tokenExchange.tokenEndpoint` | url | required | — |
72
+ | `tokenExchange.clientId` | string | required | — |
73
+ | `tokenExchange.clientSecret` | `{ fromEnv: string }` | required | — |
74
+ | `tokenExchange.clientAuth` | `"basic"` \| `"post"` | `"basic"` | `client_secret_basic` or `client_secret_post` |
75
+ | `tokenExchange.audience` | string | not sent | — |
76
+ | `tokenExchange.resource` | url | not sent | — |
77
+ | `tokenExchange.scope` | string | not sent | — |
78
+ | `tokenExchange.schemes` | array of string, min 1 | required | The security scheme names the exchanged token satisfies |
79
+ | `source` | string, non-empty | required | A local file path (relative to the config file) or an `http(s)://` URL to the OpenAPI document |
80
+ | `baseUrl` | url | not set | Replaces the document's root `servers` |
81
+ | `serverVariables` | record\<string, string\> | not set | Overrides for the document's server variable defaults |
82
+ | `hoistPathPrefix` | string starting with `/` | not set | A leading path segment moved from every route into the base URL |
83
+ | `outputSchema` | `"document"` \| `"omit"` | `"document"` | `omit` for a backend whose responses do not match its document |
84
+ | `requestBodyRequired` | `"document"` \| `"always"` | `"document"` | `always` treats an undeclared `requestBody.required` as `true` |
85
+ | `strict` | boolean | `false` | Raises `openapi_document_invalid` from a warning to a fatal diagnostic |
86
+ | `selection.default` | `"include"` \| `"exclude"` | `"exclude"` | Whether an operation is exposed when no rule matches it (opt-in by default) |
87
+ | `selection.rules` | array of `{ route?, method?, decision }` | `[]` | `route`/`method` are matched the same way the embedded SDKs match a controller route |
88
+ | `names` | record\<operation key, string\> | `{}` | Overrides a tool's name; the value must match `^[a-z][a-z0-9_]{0,255}$` |
89
+ | `credentials` | record\<security scheme name, credential\> | `{}` | Either `{ "value": { "fromEnv": "..." } }` or `{ "username": { "fromEnv": "..." }, "password": { "fromEnv": "..." } }`; see [credentials.md](../../http/spec/credentials.md) |
90
+ | `allowHosts` | array of string | `[]` | Hosts a backend operation may be served from, beyond the document's own root server |
91
+ | `refHosts` | array of string | `[]` | Hosts an external `$ref` may be fetched from, beyond the document's own host. Kept apart from `allowHosts`, so allowing a schema host never lets calls or credentials go there |
92
+ | `identityCookies` | array of string | `[]` | Cookie names treated as identity carriers on top of the default deny-list (compared case-insensitively); they extend it and never replace it |
93
+ | `limits.timeoutMs` | positive int | `30000` | Per-invocation deadline |
94
+ | `limits.maxResponseBytes` | positive int | `262144` | Response byte cap, applied while the body streams |
95
+ | `limits.maxInlineFileBytes` | positive int | `1048576` | — |
96
+
97
+ A `credentials` entry is keyed by the security scheme name it satisfies; a `tokenExchange.schemes`
98
+ entry marks that same scheme as satisfied by the exchanged token instead. `credentials` values are
99
+ never inlined — only a reference to an environment variable — so a config file can be committed
100
+ and shared.
101
+
102
+ ## Rules
103
+
104
+ - **Only `src/net/fetch.ts` reaches the network.** It is the one place the host allowlist, the
105
+ manual redirect handling and the byte cap are applied; `src/transport/http.ts` may use
106
+ `node:http` only to _listen_, never to call out. Redirects are never followed — a followed
107
+ redirect could carry a request, and its credential, to a host the allowlist never approved.
108
+ - **Only `src/platform/files.ts` touches the filesystem.** A file the document references through
109
+ an external `$ref`, or named by `source`, is checked against the directory the document was
110
+ read from on its _realpath_, so neither a `../` segment nor a symbolic link can escape it.
111
+ - **The caller's MCP token is never forwarded to the backend.** Its audience is this server, not
112
+ the backend; forwarding it would bypass the backend's own audience check. On the `http`
113
+ transport with `tokenExchange` configured, the caller's token is exchanged (RFC 8693) for a
114
+ backend-scoped token at the transport's bearer gate, and only the exchanged token reaches
115
+ `invoke_tool`.
116
+ - **An `http` transport without `tokenExchange` authenticates no caller**, so it may only bind a
117
+ loopback host (`127.0.0.1`, `::1`, `localhost`); otherwise the operator's static credentials
118
+ would be reachable from the network. Configuring `tokenExchange` on the `stdio` transport is
119
+ likewise refused (`token_exchange_requires_http`) — there is no caller token on `stdio` to
120
+ exchange.
121
+ - Visibility is not enforcement: an operation's `security` says which credential a call needs, not
122
+ which caller may make it, so the catalog reports every operation's identity as `unknown` unless
123
+ its document says `security: []`.
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ pnpm turbo run build --filter=@sezzlee/openapi-mcp
129
+ pnpm turbo run lint --filter=@sezzlee/openapi-mcp
130
+ pnpm turbo run check-types --filter=@sezzlee/openapi-mcp
131
+ pnpm turbo run test --filter=@sezzlee/openapi-mcp
132
+ ```
133
+
134
+ Run these through Turbo, not `pnpm --filter @sezzlee/openapi-mcp <task>` — the bare filter skips
135
+ `dependsOn: ["build"]` and the test task would run against a stale `dist`.
136
+
137
+ `test/` holds four suites:
138
+
139
+ - `gateway.spec.ts`, `http.spec.ts` — unit tests, no environment variables needed.
140
+ - `acceptance.spec.ts` — skipped unless `SEZZLEE_OPENAPI_ACCEPTANCE_DOC` names a path to a real
141
+ backend's OpenAPI document. The document itself never enters the repository; the suite ingests
142
+ it, builds a catalog, asserts there is no fatal diagnostic, and prints the diagnostic summary
143
+ instead of pinning a snapshot that would copy that backend's surface into the tree.
144
+ - `parity.spec.ts` — skipped unless `SEZZLEE_PARITY_DIR` names a directory of fixtures written by
145
+ `sdks/dotnet/tests/Sezzlee.Tests/OpenApiParityDump.cs`, comparing this ingestion's output against
146
+ the .NET SDK's.
147
+
148
+ Both `SEZZLEE_OPENAPI_ACCEPTANCE_DOC` and `SEZZLEE_PARITY_DIR` are declared in `turbo.json`'s `test`
149
+ task so Turbo passes them through.
@@ -0,0 +1,25 @@
1
+ import { type CatalogBuild, type CatalogDiagnostic } from "@sezzlee/core";
2
+ import { type IngestionDiagnostic, type SecurityModel, type SourcedEndpoint } from "@sezzlee/openapi";
3
+ import type { DocumentLoader } from "@sezzlee/openapi";
4
+ import { type ChosenCredentials } from "../credentials/credentials.js";
5
+ import type { GatewayConfig, ResolvedCredential } from "../platform/config.js";
6
+ export interface GatewaySource {
7
+ readonly endpoint: SourcedEndpoint;
8
+ readonly baseUrl: string;
9
+ readonly credentials: ChosenCredentials;
10
+ }
11
+ export interface GatewayCatalog {
12
+ readonly catalog: CatalogBuild<GatewaySource>;
13
+ readonly allowedHosts: ReadonlySet<string>;
14
+ readonly security: SecurityModel;
15
+ readonly ingestion: readonly IngestionDiagnostic[];
16
+ readonly dropped: readonly CatalogDiagnostic[];
17
+ }
18
+ /**
19
+ * @param document the parsed document, or its text
20
+ * @param configuredHosts hosts the operator allows beyond the root server; an operation whose
21
+ * server is neither is dropped with `server_host_not_allowed`
22
+ */
23
+ export declare function buildGatewayCatalog(document: string | Record<string, unknown>, config: GatewayConfig, credentials: ReadonlyMap<string, ResolvedCredential>, configuredHosts: readonly string[], documentUrl: string | undefined, loader: DocumentLoader | undefined): Promise<GatewayCatalog>;
24
+ /** One line per diagnostic code, with its count and the first locations, so a large document stays readable. */
25
+ export declare function summarize(ingestion: readonly IngestionDiagnostic[], catalog: readonly CatalogDiagnostic[]): string[];
@@ -0,0 +1,109 @@
1
+ import { asciiLower } from "../platform/ascii.js";
2
+ import { buildCatalog, severityIn, } from "@sezzlee/core";
3
+ import { ingest, } from "@sezzlee/openapi";
4
+ import { chooseCredentials, } from "../credentials/credentials.js";
5
+ /**
6
+ * @param document the parsed document, or its text
7
+ * @param configuredHosts hosts the operator allows beyond the root server; an operation whose
8
+ * server is neither is dropped with `server_host_not_allowed`
9
+ */
10
+ export async function buildGatewayCatalog(document, config, credentials, configuredHosts, documentUrl, loader) {
11
+ const result = await ingest(document, {
12
+ ...(documentUrl === undefined ? {} : { documentUrl }),
13
+ ...(config.baseUrl === undefined ? {} : { baseUrl: config.baseUrl }),
14
+ ...(config.serverVariables === undefined
15
+ ? {}
16
+ : { serverVariables: config.serverVariables }),
17
+ ...(loader === undefined ? {} : { loader }),
18
+ strict: config.strict,
19
+ identityCookies: config.identityCookies,
20
+ outputSchema: config.outputSchema,
21
+ requestBodyRequired: config.requestBodyRequired,
22
+ ...(config.hoistPathPrefix === undefined
23
+ ? {}
24
+ : { hoistPathPrefix: config.hoistPathPrefix }),
25
+ });
26
+ const allowedHosts = new Set(configuredHosts.map((host) => asciiLower(host)));
27
+ if (result.rootBaseUrl !== undefined) {
28
+ allowedHosts.add(asciiLower(new URL(result.rootBaseUrl).host));
29
+ }
30
+ const dropped = [];
31
+ const candidates = result.endpoints.flatMap((endpoint) => {
32
+ if (endpoint.baseUrl === undefined) {
33
+ return [];
34
+ }
35
+ const host = asciiLower(new URL(endpoint.baseUrl).host);
36
+ if (!allowedHosts.has(host)) {
37
+ dropped.push({
38
+ code: "server_host_not_allowed",
39
+ message: `${endpoint.key} is served by '${host}', which is not on the allowlist.`,
40
+ });
41
+ return [];
42
+ }
43
+ const chosen = chooseCredentials(endpoint.security, result.security, credentials);
44
+ if (chosen === undefined) {
45
+ dropped.push({
46
+ code: "security_unsatisfiable",
47
+ message: `${endpoint.key} needs a credential none of the configured ones satisfies.`,
48
+ });
49
+ return [];
50
+ }
51
+ const name = config.names[endpoint.key];
52
+ const searchTerms = config.searchTerms[endpoint.key];
53
+ const descriptor = name === undefined
54
+ ? endpoint.descriptor
55
+ : { ...endpoint.descriptor, toolName: name };
56
+ return [
57
+ {
58
+ source: { endpoint, baseUrl: endpoint.baseUrl, credentials: chosen },
59
+ owner: endpoint.key,
60
+ descriptor,
61
+ ...(descriptor.tags === undefined ? {} : { tags: descriptor.tags }),
62
+ ...(searchTerms === undefined ? {} : { searchTerms }),
63
+ declare: (tags) => ({
64
+ ...descriptor,
65
+ ...(tags === undefined ? {} : { tags: [...tags] }),
66
+ }),
67
+ },
68
+ ];
69
+ });
70
+ const catalog = buildCatalog(candidates, {
71
+ selection: config.selection,
72
+ providers: new Set(),
73
+ severity: (code) => severityIn({
74
+ name_collision: "fatal",
75
+ invalid_name: "fatal",
76
+ ambiguous_selection: "fatal",
77
+ }, code),
78
+ failOn: "fatal",
79
+ prior: dropped,
80
+ });
81
+ return {
82
+ catalog,
83
+ allowedHosts,
84
+ security: result.security,
85
+ ingestion: result.diagnostics,
86
+ dropped,
87
+ };
88
+ }
89
+ /** One line per diagnostic code, with its count and the first locations, so a large document stays readable. */
90
+ export function summarize(ingestion, catalog) {
91
+ const groups = new Map();
92
+ const add = (code, severity, sample) => {
93
+ const group = groups.get(code) ?? { severity, count: 0, samples: [] };
94
+ group.count += 1;
95
+ if (group.samples.length < 3) {
96
+ group.samples.push(sample);
97
+ }
98
+ groups.set(code, group);
99
+ };
100
+ for (const diagnostic of ingestion) {
101
+ add(diagnostic.code, diagnostic.severity, diagnostic.at === "" ? "(document)" : diagnostic.at);
102
+ }
103
+ for (const diagnostic of catalog) {
104
+ add(diagnostic.code, "catalog", diagnostic.message);
105
+ }
106
+ return [...groups.entries()]
107
+ .sort(([a], [b]) => a.localeCompare(b))
108
+ .map(([code, group]) => `${group.severity} ${code} ×${String(group.count)}: ${group.samples.join(" | ")}`);
109
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from "node:url";
3
+ import { resolve } from "node:path";
4
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
+ import { buildGatewayCatalog, summarize } from "./catalog/build.js";
6
+ import { createBoundedFetch, HostNotAllowed } from "./net/fetch.js";
7
+ import { asciiLower } from "./platform/ascii.js";
8
+ import { readConfig } from "./platform/config.js";
9
+ import { directoryOf, readText, readWithin } from "./platform/files.js";
10
+ import { createOpenApiMcpServer } from "./server.js";
11
+ import { createTokenExchange } from "./credentials/token-exchange.js";
12
+ import { serveHttp } from "./transport/http.js";
13
+ function stop(message, code) {
14
+ process.stderr.write(`${message}\n`);
15
+ process.exit(code);
16
+ }
17
+ /**
18
+ * Guard: the config path is read by name so `turbo/no-undeclared-env-vars` forces it into
19
+ * `passThroughEnv`. The secrets a config names are read through the lookup below, the one place
20
+ * this package reads an arbitrary variable, because their names belong to the operator.
21
+ */
22
+ const configPath = process.env["SEZZLEE_OPENAPI_CONFIG"];
23
+ if (configPath === undefined || configPath === "") {
24
+ stop("sezzlee-openapi reads its config from the file SEZZLEE_OPENAPI_CONFIG names.", 2);
25
+ }
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(await readText(configPath));
29
+ }
30
+ catch (error) {
31
+ stop(`sezzlee-openapi cannot read its config: ${error.message}`, 2);
32
+ }
33
+ const outcome = readConfig(raw, (name) => process.env[name]);
34
+ if (outcome.kind === "invalid") {
35
+ stop(outcome.reason, 2);
36
+ }
37
+ const { config, credentials } = outcome;
38
+ const isUrl = /^https?:\/\//i.test(config.source);
39
+ const sourcePath = isUrl
40
+ ? undefined
41
+ : resolve(directoryOf(configPath), config.source);
42
+ const documentUrl = isUrl
43
+ ? config.source
44
+ : new URL(`file://${sourcePath ?? ""}`).href;
45
+ const documentHost = isUrl
46
+ ? asciiLower(new URL(config.source).host)
47
+ : undefined;
48
+ const documentFetch = createBoundedFetch(new Set([
49
+ ...(documentHost === undefined ? [] : [documentHost]),
50
+ ...config.refHosts.map((host) => asciiLower(host)),
51
+ ]));
52
+ const documentLimit = 64 * 1024 * 1024;
53
+ async function readDocument(url) {
54
+ if (url.protocol === "file:") {
55
+ return readWithin(directoryOf(sourcePath ?? "."), fileURLToPath(url));
56
+ }
57
+ const response = await documentFetch({
58
+ method: "GET",
59
+ url,
60
+ headers: {
61
+ accept: "application/json, application/yaml;q=0.9, */*;q=0.1",
62
+ },
63
+ }, AbortSignal.timeout(config.limits.timeoutMs), documentLimit);
64
+ if (response.status !== 200 || response.body === undefined) {
65
+ throw new Error(`sezzlee-openapi: ${url.href} answered ${String(response.status)}.`);
66
+ }
67
+ return response.body;
68
+ }
69
+ const loader = readDocument;
70
+ let text;
71
+ try {
72
+ text = isUrl
73
+ ? await readDocument(new URL(config.source))
74
+ : await readText(sourcePath ?? "");
75
+ }
76
+ catch (error) {
77
+ stop(`sezzlee-openapi cannot read the document: ${error.message}`, 2);
78
+ }
79
+ const gateway = await buildGatewayCatalog(text, config, credentials, config.allowHosts, documentUrl, loader).catch((error) => stop(error instanceof HostNotAllowed
80
+ ? `sezzlee-openapi: the document references a schema on '${error.host}', which is not a reference host; add it to "refHosts" in the config to allow it.`
81
+ : `sezzlee-openapi cannot resolve the document: ${error.message}`, 2));
82
+ for (const line of summarize(gateway.ingestion, gateway.catalog.diagnostics)) {
83
+ process.stderr.write(`${line}\n`);
84
+ }
85
+ process.stderr.write(`sezzlee-openapi: ${String(gateway.catalog.entries.length)} tool(s) from ${String(gateway.catalog.selected)} selected operation(s).\n`);
86
+ if (gateway.catalog.fatal.length > 0 ||
87
+ gateway.ingestion.some((d) => d.severity === "fatal")) {
88
+ stop("sezzlee-openapi: the catalog has fatal diagnostics; see above.", 1);
89
+ }
90
+ const fetcher = createBoundedFetch(gateway.allowedHosts);
91
+ const factory = () => createOpenApiMcpServer(gateway, fetcher, config.limits);
92
+ if (config.transport.kind === "http") {
93
+ const exchangeConfig = config.tokenExchange;
94
+ const exchange = exchangeConfig === undefined
95
+ ? undefined
96
+ : createTokenExchange(exchangeConfig, outcome.clientSecret ?? "", createBoundedFetch(new Set([asciiLower(new URL(exchangeConfig.tokenEndpoint).host)])), config.limits.timeoutMs);
97
+ const listening = await serveHttp(factory, exchange, config.transport);
98
+ process.stderr.write(`sezzlee-openapi: listening on http://${config.transport.host}:${String(config.transport.port)}${config.transport.path}\n`);
99
+ const close = () => {
100
+ listening.close();
101
+ };
102
+ process.once("SIGINT", close);
103
+ process.once("SIGTERM", close);
104
+ }
105
+ else {
106
+ serveOverStdio();
107
+ }
108
+ function serveOverStdio() {
109
+ const handle = serveStdio(factory, {
110
+ onerror: (error) => {
111
+ process.stderr.write(`${error.message}\n`);
112
+ },
113
+ });
114
+ const shutdown = () => {
115
+ void handle.close();
116
+ };
117
+ process.once("SIGINT", shutdown);
118
+ process.once("SIGTERM", shutdown);
119
+ }
@@ -0,0 +1,32 @@
1
+ import type { SecurityModel, SecurityRequirement, SecurityScheme } from "@sezzlee/openapi";
2
+ import type { ResolvedCredential } from "../platform/config.js";
3
+ export interface Placement {
4
+ readonly scheme: SecurityScheme;
5
+ readonly credential: ResolvedCredential;
6
+ }
7
+ /** The credentials one invocation writes; empty for an anonymous alternative. */
8
+ export type ChosenCredentials = readonly Placement[];
9
+ /**
10
+ * Picks the first alternative whose every scheme a configured credential satisfies. Applying every
11
+ * configured scheme at once was rejected: it sends the backend credentials it did not ask for.
12
+ *
13
+ * @returns the placements to write, or `undefined` when no alternative can be satisfied
14
+ */
15
+ export declare function chooseCredentials(requirements: readonly SecurityRequirement[] | undefined, schemes: SecurityModel, credentials: ReadonlyMap<string, ResolvedCredential>): ChosenCredentials | undefined;
16
+ export interface OutboundSlots {
17
+ readonly headers: Record<string, string>;
18
+ readonly queryPairs: string[];
19
+ cookie: string | undefined;
20
+ }
21
+ /**
22
+ * Writes the chosen credentials into their slots. A credential cookie comes before a composed one,
23
+ * and a composed cookie with the same name is `cookie_carrier_collision` rather than an overwrite.
24
+ */
25
+ export declare class ExchangedTokenMissing extends Error {
26
+ constructor();
27
+ }
28
+ /**
29
+ * @param exchanged the backend token the caller's own token was exchanged for; required when a
30
+ * placement is satisfied by token exchange
31
+ */
32
+ export declare function applyCredentials(placements: ChosenCredentials, slots: OutboundSlots, exchanged?: string): void;
@@ -0,0 +1,102 @@
1
+ import { asciiLower, asciiUpper } from "../platform/ascii.js";
2
+ import { mergeCookieHeader } from "@sezzlee/core";
3
+ function fits(scheme, credential) {
4
+ if (credential === undefined) {
5
+ return false;
6
+ }
7
+ switch (scheme.type) {
8
+ case "apiKey":
9
+ return credential.kind === "value";
10
+ case "http":
11
+ return scheme.scheme === "basic"
12
+ ? credential.kind === "basic"
13
+ : credential.kind === "value" || credential.kind === "exchanged";
14
+ case "oauth2":
15
+ case "openIdConnect":
16
+ return credential.kind === "exchanged";
17
+ case "mutualTLS":
18
+ return false;
19
+ }
20
+ }
21
+ /**
22
+ * Picks the first alternative whose every scheme a configured credential satisfies. Applying every
23
+ * configured scheme at once was rejected: it sends the backend credentials it did not ask for.
24
+ *
25
+ * @returns the placements to write, or `undefined` when no alternative can be satisfied
26
+ */
27
+ export function chooseCredentials(requirements, schemes, credentials) {
28
+ if (requirements === undefined || requirements.length === 0) {
29
+ return [];
30
+ }
31
+ for (const alternative of requirements) {
32
+ const placements = [];
33
+ let satisfied = true;
34
+ for (const ref of alternative.keys()) {
35
+ const scheme = schemes.get(ref);
36
+ const credential = credentials.get(ref);
37
+ if (scheme === undefined || !fits(scheme, credential)) {
38
+ satisfied = false;
39
+ break;
40
+ }
41
+ placements.push({ scheme, credential });
42
+ }
43
+ if (satisfied) {
44
+ return placements;
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+ const encode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${asciiUpper(c.charCodeAt(0).toString(16))}`);
50
+ /**
51
+ * Writes the chosen credentials into their slots. A credential cookie comes before a composed one,
52
+ * and a composed cookie with the same name is `cookie_carrier_collision` rather than an overwrite.
53
+ */
54
+ export class ExchangedTokenMissing extends Error {
55
+ constructor() {
56
+ super("sezzlee-openapi: the operation needs an exchanged token, and the call carried none.");
57
+ this.name = "ExchangedTokenMissing";
58
+ }
59
+ }
60
+ /**
61
+ * @param exchanged the backend token the caller's own token was exchanged for; required when a
62
+ * placement is satisfied by token exchange
63
+ */
64
+ export function applyCredentials(placements, slots, exchanged) {
65
+ const credentialCookies = [];
66
+ for (const { scheme, credential } of placements) {
67
+ if (credential.kind === "exchanged" && exchanged === undefined) {
68
+ throw new ExchangedTokenMissing();
69
+ }
70
+ const value = credential.kind === "value"
71
+ ? credential.value
72
+ : credential.kind === "exchanged"
73
+ ? (exchanged ?? "")
74
+ : "";
75
+ switch (scheme.type) {
76
+ case "apiKey":
77
+ if (scheme.in === "header") {
78
+ slots.headers[asciiLower(scheme.name)] = value;
79
+ }
80
+ else if (scheme.in === "query") {
81
+ slots.queryPairs.push(`${encode(scheme.name)}=${encode(value)}`);
82
+ }
83
+ else {
84
+ credentialCookies.push(`${scheme.name}=${value}`);
85
+ }
86
+ break;
87
+ case "http":
88
+ case "oauth2":
89
+ case "openIdConnect":
90
+ slots.headers["authorization"] =
91
+ credential.kind === "basic"
92
+ ? `Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}`
93
+ : `Bearer ${value}`;
94
+ break;
95
+ case "mutualTLS":
96
+ break;
97
+ }
98
+ }
99
+ if (credentialCookies.length > 0) {
100
+ slots.cookie = mergeCookieHeader(credentialCookies.join("; "), slots.cookie);
101
+ }
102
+ }
@@ -0,0 +1,24 @@
1
+ import type { BoundedFetch } from "../net/fetch.js";
2
+ import type { TokenExchangeConfig } from "../platform/config.js";
3
+ export interface ExchangedToken {
4
+ readonly token: string;
5
+ /** Unix seconds. */
6
+ readonly expiresAt: number;
7
+ }
8
+ export declare class TokenExchangeFailed extends Error {
9
+ readonly rejected: boolean;
10
+ constructor(rejected: boolean, message: string);
11
+ }
12
+ export interface TokenExchange {
13
+ exchange(subjectToken: string): Promise<ExchangedToken>;
14
+ }
15
+ /**
16
+ * RFC 8693 token exchange against one authorization server.
17
+ *
18
+ * Guard: the cache key is a digest of the subject token, never the token, so a heap dump of the
19
+ * cache does not hold a usable credential; an exchanged token never outlives the subject token it
20
+ * was issued for; a failure is not cached, so a caller whose grant is restored is not locked out
21
+ * until an expiry; the token endpoint's body is never surfaced, for the reason a backend's 401 body
22
+ * is not — it describes the credential, not the call.
23
+ */
24
+ export declare function createTokenExchange(config: TokenExchangeConfig, clientSecret: string, fetcher: BoundedFetch, timeoutMs: number, now?: () => number): TokenExchange;