@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 +21 -0
- package/README.md +148 -2
- package/dist/catalog/build.d.ts +25 -0
- package/dist/catalog/build.js +109 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +119 -0
- package/dist/credentials/credentials.d.ts +32 -0
- package/dist/credentials/credentials.js +102 -0
- package/dist/credentials/token-exchange.d.ts +24 -0
- package/dist/credentials/token-exchange.js +116 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/invoke/invoke.d.ts +14 -0
- package/dist/invoke/invoke.js +118 -0
- package/dist/net/fetch.d.ts +25 -0
- package/dist/net/fetch.js +97 -0
- package/dist/platform/ascii.d.ts +7 -0
- package/dist/platform/ascii.js +7 -0
- package/dist/platform/config.d.ts +106 -0
- package/dist/platform/config.js +163 -0
- package/dist/platform/files.d.ts +9 -0
- package/dist/platform/files.js +21 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +121 -0
- package/dist/transport/http.d.ts +23 -0
- package/dist/transport/http.js +88 -0
- package/package.json +66 -3
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
|
-
#
|
|
1
|
+
# @sezzlee/openapi-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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
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;
|