@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.
@@ -0,0 +1,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SingleFlight } from "@sezzlee/core";
3
+ export class TokenExchangeFailed extends Error {
4
+ rejected;
5
+ constructor(rejected, message) {
6
+ super(message);
7
+ this.rejected = rejected;
8
+ this.name = "TokenExchangeFailed";
9
+ }
10
+ }
11
+ const skewSeconds = 30;
12
+ const maxTokenResponseBytes = 64 * 1024;
13
+ function subjectExpiry(token) {
14
+ const payload = token.split(".")[1];
15
+ if (payload === undefined) {
16
+ return undefined;
17
+ }
18
+ try {
19
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
20
+ return typeof claims.exp === "number" ? claims.exp : undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ /**
27
+ * RFC 8693 token exchange against one authorization server.
28
+ *
29
+ * Guard: the cache key is a digest of the subject token, never the token, so a heap dump of the
30
+ * cache does not hold a usable credential; an exchanged token never outlives the subject token it
31
+ * was issued for; a failure is not cached, so a caller whose grant is restored is not locked out
32
+ * until an expiry; the token endpoint's body is never surfaced, for the reason a backend's 401 body
33
+ * is not — it describes the credential, not the call.
34
+ */
35
+ export function createTokenExchange(config, clientSecret, fetcher, timeoutMs, now = () => Math.floor(Date.now() / 1000)) {
36
+ const cache = new Map();
37
+ const flight = new SingleFlight();
38
+ const endpoint = new URL(config.tokenEndpoint);
39
+ async function request(subjectToken) {
40
+ const form = new URLSearchParams({
41
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
42
+ subject_token: subjectToken,
43
+ subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
44
+ requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
45
+ });
46
+ if (config.audience !== undefined) {
47
+ form.set("audience", config.audience);
48
+ }
49
+ if (config.resource !== undefined) {
50
+ form.set("resource", config.resource);
51
+ }
52
+ if (config.scope !== undefined) {
53
+ form.set("scope", config.scope);
54
+ }
55
+ const headers = {
56
+ "content-type": "application/x-www-form-urlencoded",
57
+ accept: "application/json",
58
+ };
59
+ if (config.clientAuth === "basic") {
60
+ headers["authorization"] =
61
+ `Basic ${Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(clientSecret)}`, "utf8").toString("base64")}`;
62
+ }
63
+ else {
64
+ form.set("client_id", config.clientId);
65
+ form.set("client_secret", clientSecret);
66
+ }
67
+ const response = await fetcher({
68
+ method: "POST",
69
+ url: endpoint,
70
+ headers,
71
+ body: new TextEncoder().encode(form.toString()),
72
+ }, AbortSignal.timeout(timeoutMs), maxTokenResponseBytes);
73
+ if (response.status >= 500) {
74
+ throw new TokenExchangeFailed(false, `The authorization server answered ${String(response.status)} to the token exchange.`);
75
+ }
76
+ let body = {};
77
+ try {
78
+ body = JSON.parse(response.body ?? "{}");
79
+ }
80
+ catch {
81
+ body = {};
82
+ }
83
+ if (response.status !== 200 || typeof body.access_token !== "string") {
84
+ throw new TokenExchangeFailed(true, "The authorization server refused to exchange the token.");
85
+ }
86
+ const issuedFor = typeof body.expires_in === "number"
87
+ ? now() + body.expires_in
88
+ : now() + 300;
89
+ const subjectExp = subjectExpiry(subjectToken);
90
+ return {
91
+ token: body.access_token,
92
+ expiresAt: subjectExp === undefined ? issuedFor : Math.min(issuedFor, subjectExp),
93
+ };
94
+ }
95
+ return {
96
+ async exchange(subjectToken) {
97
+ const key = createHash("sha256")
98
+ .update(subjectToken)
99
+ .update("\0")
100
+ .update(config.audience ?? "")
101
+ .update("\0")
102
+ .update(config.resource ?? "")
103
+ .update("\0")
104
+ .update(config.scope ?? "")
105
+ .digest("hex");
106
+ const cached = cache.get(key);
107
+ if (cached !== undefined && cached.expiresAt - skewSeconds > now()) {
108
+ return cached;
109
+ }
110
+ cache.delete(key);
111
+ const issued = await flight.run(key, () => request(subjectToken));
112
+ cache.set(key, issued);
113
+ return issued;
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,11 @@
1
+ export { buildGatewayCatalog, summarize } from "./catalog/build.js";
2
+ export type { GatewayCatalog, GatewaySource } from "./catalog/build.js";
3
+ export { chooseCredentials, applyCredentials, } from "./credentials/credentials.js";
4
+ export { createBoundedFetch, HostNotAllowed, ResponseTooLarge, } from "./net/fetch.js";
5
+ export type { BoundedFetch, OutboundRequest } from "./net/fetch.js";
6
+ export { invokeEntry } from "./invoke/invoke.js";
7
+ export type { InvokeLimits, InvokeTarget } from "./invoke/invoke.js";
8
+ export { configSchema, readConfig } from "./platform/config.js";
9
+ export type { GatewayConfig, ResolvedCredential } from "./platform/config.js";
10
+ export { createOpenApiMcpServer } from "./server.js";
11
+ export type { OpenApiMcpServerOptions } from "./server.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { buildGatewayCatalog, summarize } from "./catalog/build.js";
2
+ export { chooseCredentials, applyCredentials, } from "./credentials/credentials.js";
3
+ export { createBoundedFetch, HostNotAllowed, ResponseTooLarge, } from "./net/fetch.js";
4
+ export { invokeEntry } from "./invoke/invoke.js";
5
+ export { configSchema, readConfig } from "./platform/config.js";
6
+ export { createOpenApiMcpServer } from "./server.js";
@@ -0,0 +1,14 @@
1
+ import { type CatalogEntry, type MetaResponse } from "@sezzlee/core";
2
+ import type { GatewaySource } from "../catalog/build.js";
3
+ import { type BoundedFetch } from "../net/fetch.js";
4
+ export interface InvokeTarget {
5
+ readonly tool: string;
6
+ readonly method: string;
7
+ readonly route: string;
8
+ }
9
+ export interface InvokeLimits {
10
+ readonly timeoutMs: number;
11
+ readonly maxResponseBytes: number;
12
+ readonly maxInlineFileBytes: number;
13
+ }
14
+ export declare function invokeEntry(entry: CatalogEntry<GatewaySource>, args: unknown, fetcher: BoundedFetch, limits: InvokeLimits, signal: AbortSignal | undefined, exchanged?: string): Promise<MetaResponse<InvokeTarget>>;
@@ -0,0 +1,118 @@
1
+ import { asciiLower } from "../platform/ascii.js";
2
+ import { armDeadline, compose, errorResult, isInvokeError, knownFields, mapInvokeResult, narrowingArguments, normalizeInvokeArguments, notInvocable, refuseTimedOutInvoke, sdkError, SezzleeArgumentError, SezzleeDispatchAborted, textResult, untilAbandoned, vocabularyOf, writeBody, } from "@sezzlee/core";
3
+ import { applyCredentials, ExchangedTokenMissing, } from "../credentials/credentials.js";
4
+ import { HostNotAllowed, ResponseTooLarge, } from "../net/fetch.js";
5
+ const userAgent = "sezzlee-openapi/0.0.0";
6
+ /**
7
+ * Guard: a `ref` file argument needs a resolver, and this server binds none, so the composer never
8
+ * offers `ref` and this path is unreachable unless a template was built with one.
9
+ */
10
+ const noRefResolver = () => {
11
+ throw new SezzleeArgumentError("invalid_file_argument", "This server accepts file contents inline only; send 'text' or 'base64'.");
12
+ };
13
+ export async function invokeEntry(entry, args, fetcher, limits, signal, exchanged) {
14
+ const template = entry.template;
15
+ if (template === undefined) {
16
+ return notInvocable(entry.tool.name);
17
+ }
18
+ const target = {
19
+ tool: entry.tool.name,
20
+ method: entry.descriptor.method,
21
+ route: entry.descriptor.route,
22
+ };
23
+ const normalized = normalizeInvokeArguments(args);
24
+ let composed;
25
+ try {
26
+ composed = compose(template, normalized.value, undefined, {
27
+ maxInlineFileBytes: limits.maxInlineFileBytes,
28
+ });
29
+ }
30
+ catch (error) {
31
+ if (error instanceof SezzleeArgumentError) {
32
+ return errorResult(error.code, error.message);
33
+ }
34
+ throw error;
35
+ }
36
+ const headers = {
37
+ accept: "application/json, */*;q=0.5",
38
+ "user-agent": userAgent,
39
+ };
40
+ let cookie;
41
+ for (const [name, value] of Object.entries(composed.headers)) {
42
+ if (asciiLower(name) === "cookie") {
43
+ cookie = value;
44
+ }
45
+ else {
46
+ headers[asciiLower(name)] = value;
47
+ }
48
+ }
49
+ const slots = { headers, queryPairs: [], cookie };
50
+ try {
51
+ applyCredentials(entry.credentials, slots, exchanged);
52
+ }
53
+ catch (error) {
54
+ if (error instanceof SezzleeArgumentError) {
55
+ return errorResult(error.code, error.message);
56
+ }
57
+ if (error instanceof ExchangedTokenMissing) {
58
+ return errorResult("not_invocable", `Operation '${entry.tool.name}' acts as the caller, and this session carries no caller token.`);
59
+ }
60
+ throw error;
61
+ }
62
+ if (slots.cookie !== undefined) {
63
+ headers["cookie"] = slots.cookie;
64
+ }
65
+ const extraQuery = slots.queryPairs.join("&");
66
+ const pathAndQuery = extraQuery === ""
67
+ ? composed.pathAndQuery
68
+ : `${composed.pathAndQuery}${composed.pathAndQuery.includes("?") ? "&" : "?"}${extraQuery}`;
69
+ const url = new URL(`${entry.baseUrl}${pathAndQuery}`);
70
+ const deadline = armDeadline({
71
+ ...(signal === undefined ? {} : { signal }),
72
+ timeoutMs: limits.timeoutMs,
73
+ });
74
+ try {
75
+ const written = composed.body === undefined
76
+ ? undefined
77
+ : await untilAbandoned(writeBody(composed.body, noRefResolver), deadline.signal, deadline.reason);
78
+ if (written !== undefined) {
79
+ headers["content-type"] = written.contentType;
80
+ }
81
+ const response = await fetcher({
82
+ method: template.method,
83
+ url,
84
+ headers,
85
+ ...(written === undefined ? {} : { body: written.bytes }),
86
+ }, deadline.signal, limits.maxResponseBytes);
87
+ const outcome = mapInvokeResult(response, {
88
+ knownFields: knownFields(entry.tool),
89
+ ...vocabularyOf(template),
90
+ });
91
+ return textResult(outcome, isInvokeError(outcome), {
92
+ summaryOf: isInvokeError(outcome) ? outcome : outcome.body,
93
+ narrowing: narrowingArguments(entry.tool.inputSchema),
94
+ target,
95
+ });
96
+ }
97
+ catch (error) {
98
+ const abandoned = deadline.reason();
99
+ if (abandoned === "timeout" ||
100
+ (error instanceof SezzleeDispatchAborted && error.reason === "timeout")) {
101
+ return { payload: refuseTimedOutInvoke(limits.timeoutMs), isError: true };
102
+ }
103
+ if (error instanceof ResponseTooLarge) {
104
+ return {
105
+ payload: sdkError("response_too_large", `The backend's answer exceeded ${String(error.limit)} bytes and was not read to the end; narrow the call and retry.`),
106
+ isError: true,
107
+ narrowing: narrowingArguments(entry.tool.inputSchema),
108
+ };
109
+ }
110
+ if (error instanceof HostNotAllowed) {
111
+ return errorResult("not_invocable", `Operation '${entry.tool.name}' targets a host this server may not reach.`);
112
+ }
113
+ throw error;
114
+ }
115
+ finally {
116
+ deadline.dispose();
117
+ }
118
+ }
@@ -0,0 +1,25 @@
1
+ import type { BackendResponse } from "@sezzlee/core";
2
+ export declare class HostNotAllowed extends Error {
3
+ readonly host: string;
4
+ constructor(host: string);
5
+ }
6
+ export declare class ResponseTooLarge extends Error {
7
+ readonly limit: number;
8
+ constructor(limit: number);
9
+ }
10
+ export interface OutboundRequest {
11
+ readonly method: string;
12
+ readonly url: URL;
13
+ readonly headers: Readonly<Record<string, string>>;
14
+ readonly body?: Uint8Array;
15
+ }
16
+ export type BoundedFetch = (request: OutboundRequest, signal: AbortSignal, maxBytes: number) => Promise<BackendResponse>;
17
+ /**
18
+ * The only network egress of this package.
19
+ *
20
+ * Guard: redirects are never followed. A followed redirect would carry the request — and its
21
+ * credential — to whatever host a `Location` header names, which is exactly what the allowlist
22
+ * exists to prevent; a 3xx is returned to the error mapper as it arrived. The body is read under a
23
+ * byte cap, so a backend that streams without end cannot exhaust memory before the budget check.
24
+ */
25
+ export declare function createBoundedFetch(allowedHosts: ReadonlySet<string>): BoundedFetch;
@@ -0,0 +1,97 @@
1
+ import { asciiLower } from "../platform/ascii.js";
2
+ export class HostNotAllowed extends Error {
3
+ host;
4
+ constructor(host) {
5
+ super(`sezzlee-openapi: host '${host}' is not on the allowlist.`);
6
+ this.host = host;
7
+ this.name = "HostNotAllowed";
8
+ }
9
+ }
10
+ export class ResponseTooLarge extends Error {
11
+ limit;
12
+ constructor(limit) {
13
+ super(`sezzlee-openapi: the response exceeded ${String(limit)} bytes.`);
14
+ this.limit = limit;
15
+ this.name = "ResponseTooLarge";
16
+ }
17
+ }
18
+ /**
19
+ * Guard: hop-by-hop headers describe one connection, and `set-cookie` would hand the backend's
20
+ * session to the agent; neither may reach the error mapper or a result.
21
+ */
22
+ const droppedHeaders = new Set([
23
+ "connection",
24
+ "keep-alive",
25
+ "transfer-encoding",
26
+ "upgrade",
27
+ "proxy-authenticate",
28
+ "trailer",
29
+ "set-cookie",
30
+ ]);
31
+ async function readCapped(response, maxBytes) {
32
+ if (response.body === null) {
33
+ return new Uint8Array();
34
+ }
35
+ const reader = response.body.getReader();
36
+ const chunks = [];
37
+ let total = 0;
38
+ for (;;) {
39
+ const { done, value } = await reader.read();
40
+ if (done) {
41
+ break;
42
+ }
43
+ total += value.byteLength;
44
+ if (total > maxBytes) {
45
+ await reader.cancel();
46
+ throw new ResponseTooLarge(maxBytes);
47
+ }
48
+ chunks.push(value);
49
+ }
50
+ const bytes = new Uint8Array(total);
51
+ let offset = 0;
52
+ for (const chunk of chunks) {
53
+ bytes.set(chunk, offset);
54
+ offset += chunk.byteLength;
55
+ }
56
+ return bytes;
57
+ }
58
+ /**
59
+ * The only network egress of this package.
60
+ *
61
+ * Guard: redirects are never followed. A followed redirect would carry the request — and its
62
+ * credential — to whatever host a `Location` header names, which is exactly what the allowlist
63
+ * exists to prevent; a 3xx is returned to the error mapper as it arrived. The body is read under a
64
+ * byte cap, so a backend that streams without end cannot exhaust memory before the budget check.
65
+ */
66
+ export function createBoundedFetch(allowedHosts) {
67
+ return async (request, signal, maxBytes) => {
68
+ if (!allowedHosts.has(asciiLower(request.url.host))) {
69
+ throw new HostNotAllowed(request.url.host);
70
+ }
71
+ const response = await fetch(request.url, {
72
+ method: request.method,
73
+ headers: request.headers,
74
+ redirect: "manual",
75
+ signal,
76
+ ...(request.body === undefined
77
+ ? {}
78
+ : { body: new Uint8Array(request.body) }),
79
+ });
80
+ const bytes = await readCapped(response, maxBytes);
81
+ const headers = {};
82
+ response.headers.forEach((value, name) => {
83
+ if (!droppedHeaders.has(asciiLower(name))) {
84
+ headers[asciiLower(name)] = value;
85
+ }
86
+ });
87
+ const contentType = response.headers.get("content-type") ?? undefined;
88
+ return {
89
+ status: response.status,
90
+ headers,
91
+ ...(contentType === undefined ? {} : { contentType }),
92
+ ...(bytes.byteLength === 0
93
+ ? {}
94
+ : { body: new TextDecoder().decode(bytes) }),
95
+ };
96
+ };
97
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Guard: header names, hosts and methods are ASCII by protocol, and a locale-sensitive
3
+ * `toLowerCase` turns `I` into a dotless `ı` under a Turkish locale, so a host or header would stop
4
+ * matching its allowlist or carrier entry on such a machine.
5
+ */
6
+ export declare const asciiLower: (value: string) => string;
7
+ export declare const asciiUpper: (value: string) => string;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Guard: header names, hosts and methods are ASCII by protocol, and a locale-sensitive
3
+ * `toLowerCase` turns `I` into a dotless `ı` under a Turkish locale, so a host or header would stop
4
+ * matching its allowlist or carrier entry on such a machine.
5
+ */
6
+ export const asciiLower = (value) => value.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
7
+ export const asciiUpper = (value) => value.replace(/[a-z]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 32));
@@ -0,0 +1,106 @@
1
+ import { z } from "zod";
2
+ export declare const configSchema: z.ZodObject<{
3
+ transport: z.ZodDefault<z.ZodDiscriminatedUnion<[z.ZodObject<{
4
+ kind: z.ZodLiteral<"stdio">;
5
+ }, z.core.$strict>, z.ZodObject<{
6
+ kind: z.ZodLiteral<"http">;
7
+ host: z.ZodDefault<z.ZodString>;
8
+ port: z.ZodDefault<z.ZodNumber>;
9
+ path: z.ZodDefault<z.ZodString>;
10
+ resource: z.ZodURL;
11
+ authorizationServers: z.ZodArray<z.ZodURL>;
12
+ allowedHostnames: z.ZodDefault<z.ZodArray<z.ZodString>>;
13
+ }, z.core.$strict>], "kind">>;
14
+ tokenExchange: z.ZodOptional<z.ZodObject<{
15
+ tokenEndpoint: z.ZodURL;
16
+ clientId: z.ZodString;
17
+ clientSecret: z.ZodObject<{
18
+ fromEnv: z.ZodString;
19
+ }, z.core.$strict>;
20
+ clientAuth: z.ZodDefault<z.ZodEnum<{
21
+ basic: "basic";
22
+ post: "post";
23
+ }>>;
24
+ audience: z.ZodOptional<z.ZodString>;
25
+ resource: z.ZodOptional<z.ZodURL>;
26
+ scope: z.ZodOptional<z.ZodString>;
27
+ schemes: z.ZodArray<z.ZodString>;
28
+ }, z.core.$strict>>;
29
+ source: z.ZodString;
30
+ baseUrl: z.ZodOptional<z.ZodURL>;
31
+ serverVariables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
32
+ hoistPathPrefix: z.ZodOptional<z.ZodString>;
33
+ outputSchema: z.ZodDefault<z.ZodEnum<{
34
+ document: "document";
35
+ omit: "omit";
36
+ }>>;
37
+ requestBodyRequired: z.ZodDefault<z.ZodEnum<{
38
+ always: "always";
39
+ document: "document";
40
+ }>>;
41
+ strict: z.ZodDefault<z.ZodBoolean>;
42
+ selection: z.ZodDefault<z.ZodObject<{
43
+ default: z.ZodDefault<z.ZodEnum<{
44
+ exclude: "exclude";
45
+ include: "include";
46
+ }>>;
47
+ rules: z.ZodDefault<z.ZodArray<z.ZodObject<{
48
+ route: z.ZodOptional<z.ZodString>;
49
+ method: z.ZodOptional<z.ZodString>;
50
+ decision: z.ZodEnum<{
51
+ exclude: "exclude";
52
+ include: "include";
53
+ }>;
54
+ }, z.core.$strict>>>;
55
+ }, z.core.$strict>>;
56
+ names: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
57
+ searchTerms: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
58
+ credentials: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
59
+ value: z.ZodObject<{
60
+ fromEnv: z.ZodString;
61
+ }, z.core.$strict>;
62
+ }, z.core.$strict>, z.ZodObject<{
63
+ username: z.ZodObject<{
64
+ fromEnv: z.ZodString;
65
+ }, z.core.$strict>;
66
+ password: z.ZodObject<{
67
+ fromEnv: z.ZodString;
68
+ }, z.core.$strict>;
69
+ }, z.core.$strict>]>>>;
70
+ allowHosts: z.ZodDefault<z.ZodArray<z.ZodString>>;
71
+ refHosts: z.ZodDefault<z.ZodArray<z.ZodString>>;
72
+ identityCookies: z.ZodDefault<z.ZodArray<z.ZodString>>;
73
+ limits: z.ZodDefault<z.ZodObject<{
74
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
75
+ maxResponseBytes: z.ZodDefault<z.ZodNumber>;
76
+ maxInlineFileBytes: z.ZodDefault<z.ZodNumber>;
77
+ }, z.core.$strict>>;
78
+ }, z.core.$strict>;
79
+ export type GatewayConfig = z.infer<typeof configSchema>;
80
+ export type TokenExchangeConfig = NonNullable<GatewayConfig["tokenExchange"]>;
81
+ export type ResolvedCredential = {
82
+ readonly kind: "value";
83
+ readonly value: string;
84
+ } | {
85
+ readonly kind: "exchanged";
86
+ } | {
87
+ readonly kind: "basic";
88
+ readonly username: string;
89
+ readonly password: string;
90
+ };
91
+ export type ConfigOutcome = {
92
+ readonly kind: "ok";
93
+ readonly config: GatewayConfig;
94
+ readonly credentials: ReadonlyMap<string, ResolvedCredential>;
95
+ readonly clientSecret?: string;
96
+ } | {
97
+ readonly kind: "invalid";
98
+ readonly reason: string;
99
+ };
100
+ /**
101
+ * Parses the config and resolves every secret it names.
102
+ *
103
+ * @param env reads one environment variable; secrets are named in the config, never inlined, so a
104
+ * config file can be committed and shared
105
+ */
106
+ export declare function readConfig(raw: unknown, env: (name: string) => string | undefined): ConfigOutcome;
@@ -0,0 +1,163 @@
1
+ import { invokeLimits } from "@sezzlee/core";
2
+ import { z } from "zod";
3
+ const secret = z.object({ fromEnv: z.string().min(1) }).strict();
4
+ const credential = z.union([
5
+ z.object({ value: secret }).strict(),
6
+ z.object({ username: secret, password: secret }).strict(),
7
+ ]);
8
+ const selectionRule = z
9
+ .object({
10
+ route: z.string().min(1).optional(),
11
+ method: z.string().min(1).optional(),
12
+ decision: z.enum(["include", "exclude"]),
13
+ })
14
+ .strict();
15
+ const transport = z.discriminatedUnion("kind", [
16
+ z.object({ kind: z.literal("stdio") }).strict(),
17
+ z
18
+ .object({
19
+ kind: z.literal("http"),
20
+ host: z.string().min(1).default("127.0.0.1"),
21
+ port: z.number().int().min(0).max(65535).default(8787),
22
+ path: z.string().startsWith("/").default("/mcp"),
23
+ resource: z.url(),
24
+ authorizationServers: z.array(z.url()).min(1),
25
+ allowedHostnames: z.array(z.string().min(1)).default([]),
26
+ })
27
+ .strict(),
28
+ ]);
29
+ const tokenExchange = z
30
+ .object({
31
+ tokenEndpoint: z.url(),
32
+ clientId: z.string().min(1),
33
+ clientSecret: secret,
34
+ clientAuth: z.enum(["basic", "post"]).default("basic"),
35
+ audience: z.string().min(1).optional(),
36
+ resource: z.url().optional(),
37
+ scope: z.string().min(1).optional(),
38
+ schemes: z.array(z.string().min(1)).min(1),
39
+ })
40
+ .strict();
41
+ export const configSchema = z
42
+ .object({
43
+ transport: transport.default({ kind: "stdio" }),
44
+ tokenExchange: tokenExchange.optional(),
45
+ source: z.string().min(1),
46
+ baseUrl: z.url().optional(),
47
+ serverVariables: z.record(z.string(), z.string()).optional(),
48
+ hoistPathPrefix: z.string().startsWith("/").optional(),
49
+ outputSchema: z.enum(["document", "omit"]).default("document"),
50
+ requestBodyRequired: z.enum(["document", "always"]).default("document"),
51
+ strict: z.boolean().default(false),
52
+ selection: z
53
+ .object({
54
+ default: z.enum(["include", "exclude"]).default("exclude"),
55
+ rules: z.array(selectionRule).default([]),
56
+ })
57
+ .strict()
58
+ .default({ default: "exclude", rules: [] }),
59
+ names: z
60
+ .record(z.string(), z.string().regex(/^[a-z][a-z0-9_]{0,255}$/))
61
+ .default({}),
62
+ searchTerms: z.record(z.string(), z.array(z.string())).default({}),
63
+ credentials: z.record(z.string(), credential).default({}),
64
+ allowHosts: z.array(z.string().min(1)).default([]),
65
+ /**
66
+ * Guard: kept apart from `allowHosts`. That list is where calls and their
67
+ * credentials may go; this one is where the document's author may make the
68
+ * gateway fetch schemas from. Merging them would let one entry widen both.
69
+ */
70
+ refHosts: z.array(z.string().min(1)).default([]),
71
+ identityCookies: z.array(z.string().min(1)).default([]),
72
+ limits: z
73
+ .object({
74
+ timeoutMs: z
75
+ .number()
76
+ .int()
77
+ .positive()
78
+ .default(invokeLimits.invokeTimeoutMs),
79
+ maxResponseBytes: z
80
+ .number()
81
+ .int()
82
+ .positive()
83
+ .default(invokeLimits.maxResponseBytes),
84
+ maxInlineFileBytes: z
85
+ .number()
86
+ .int()
87
+ .positive()
88
+ .default(invokeLimits.maxInlineFileBytes),
89
+ })
90
+ .strict()
91
+ .default({
92
+ timeoutMs: invokeLimits.invokeTimeoutMs,
93
+ maxResponseBytes: invokeLimits.maxResponseBytes,
94
+ maxInlineFileBytes: invokeLimits.maxInlineFileBytes,
95
+ }),
96
+ })
97
+ .strict();
98
+ /**
99
+ * Parses the config and resolves every secret it names.
100
+ *
101
+ * @param env reads one environment variable; secrets are named in the config, never inlined, so a
102
+ * config file can be committed and shared
103
+ */
104
+ export function readConfig(raw, env) {
105
+ const parsed = configSchema.safeParse(raw);
106
+ if (!parsed.success) {
107
+ return {
108
+ kind: "invalid",
109
+ reason: `The config is invalid: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")}`,
110
+ };
111
+ }
112
+ const credentials = new Map();
113
+ const missing = [];
114
+ const read = (reference) => {
115
+ const value = env(reference.fromEnv);
116
+ if (value === undefined || value === "") {
117
+ missing.push(reference.fromEnv);
118
+ return "";
119
+ }
120
+ return value;
121
+ };
122
+ for (const [scheme, declared] of Object.entries(parsed.data.credentials)) {
123
+ credentials.set(scheme, "value" in declared
124
+ ? { kind: "value", value: read(declared.value) }
125
+ : {
126
+ kind: "basic",
127
+ username: read(declared.username),
128
+ password: read(declared.password),
129
+ });
130
+ }
131
+ const exchange = parsed.data.tokenExchange;
132
+ for (const scheme of exchange?.schemes ?? []) {
133
+ credentials.set(scheme, { kind: "exchanged" });
134
+ }
135
+ const clientSecret = exchange === undefined ? undefined : read(exchange.clientSecret);
136
+ if (missing.length > 0) {
137
+ return {
138
+ kind: "invalid",
139
+ reason: `The config names environment variables that are not set: ${missing.join(", ")}.`,
140
+ };
141
+ }
142
+ const transportConfig = parsed.data.transport;
143
+ if (exchange === undefined &&
144
+ transportConfig.kind === "http" &&
145
+ !["127.0.0.1", "::1", "localhost"].includes(transportConfig.host)) {
146
+ return {
147
+ kind: "invalid",
148
+ reason: "An http transport without token exchange authenticates no caller, so it may only listen on a loopback host; configure tokenExchange or set host to 127.0.0.1.",
149
+ };
150
+ }
151
+ if (exchange !== undefined && parsed.data.transport.kind !== "http") {
152
+ return {
153
+ kind: "invalid",
154
+ reason: "token_exchange_requires_http: token exchange needs the caller's own token, which only the http transport carries; stdio has no caller to exchange for.",
155
+ };
156
+ }
157
+ return {
158
+ kind: "ok",
159
+ config: parsed.data,
160
+ credentials,
161
+ ...(clientSecret === undefined ? {} : { clientSecret }),
162
+ };
163
+ }