@adtl/gateway 0.0.0-stage → 0.1.1

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/src/gateway.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The gateway: one MCP server facing the agent, many MCP clients facing the
3
+ * upstream servers, and an interceptor chain in between.
4
+ *
5
+ * tools/list → every upstream's tools, renamed <upstream>__<tool>
6
+ * tools/call → request interceptors (adtl.policy) → upstream → response
7
+ * interceptors (adtl.label) → agent
8
+ *
9
+ * When a call is stopped, the agent receives a normal tool result with
10
+ * `isError: true` explaining why, so the model can tell the user. In v0.1 an
11
+ * "ask" decision is treated as a block; approvals arrive in v0.2.
12
+ *
13
+ * In audit mode (the invoker runs every interceptor with `mode: "audit"`)
14
+ * nothing is stopped or changed: calls the policy would stop are forwarded,
15
+ * and the audit log records what would have happened.
16
+ */
17
+ import { randomUUID } from "node:crypto";
18
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
19
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
+ import {
21
+ CallToolRequestSchema,
22
+ ListToolsRequestSchema,
23
+ type CallToolResult,
24
+ type Tool,
25
+ } from "@modelcontextprotocol/sdk/types.js";
26
+ import type { InvocationContext } from "@adtl/interceptors";
27
+ import type { AuditEntry, AuditSink } from "./audit.js";
28
+ import { TOOL_SEPARATOR } from "./config.js";
29
+ import type { Blocked, ChainOutcome, InterceptorInvoker } from "./invoker.js";
30
+
31
+ export const GATEWAY_VERSION = "0.1.1";
32
+
33
+ /** `_meta` key on gateway-generated error results describing why a call was stopped. */
34
+ export const GATEWAY_DECISION_KEY = "adtl/gateway";
35
+
36
+ interface Route {
37
+ upstream: string;
38
+ tool: string;
39
+ annotations?: Tool["annotations"];
40
+ }
41
+
42
+ export interface GatewayLogger {
43
+ info(msg: string): void;
44
+ warn(msg: string): void;
45
+ }
46
+
47
+ const stderrLogger: GatewayLogger = {
48
+ info: (m) => console.error(`[adtl-gateway] ${m}`),
49
+ warn: (m) => console.error(`[adtl-gateway] warning: ${m}`),
50
+ };
51
+
52
+ export interface AdtlGatewayOptions {
53
+ upstreams: Map<string, Client>;
54
+ invoker: InterceptorInvoker;
55
+ /** One gateway process serves one agent session over stdio. */
56
+ sessionId?: string;
57
+ audit?: AuditSink;
58
+ logger?: GatewayLogger;
59
+ }
60
+
61
+ function textError(text: string, meta: Record<string, unknown>): CallToolResult {
62
+ return { content: [{ type: "text", text }], isError: true, _meta: { [GATEWAY_DECISION_KEY]: meta } };
63
+ }
64
+
65
+ function blockedMessage(b: Blocked): string {
66
+ if (b.decision === "ask") {
67
+ return (
68
+ `ADTL stopped this call because it needs a human's approval: ${b.reason}. ` +
69
+ `This version of ADTL cannot ask for approval yet, so the call was not run. Tell the user what you were trying to do.`
70
+ );
71
+ }
72
+ return `ADTL blocked this call: ${b.reason}. The call was not run.`;
73
+ }
74
+
75
+ function infoOf(outcome: ChainOutcome, interceptor: string): Record<string, unknown> {
76
+ return outcome.results.find((r) => r.interceptor === interceptor)?.info ?? {};
77
+ }
78
+
79
+ export class AdtlGateway {
80
+ readonly sessionId: string;
81
+ private readonly upstreams: Map<string, Client>;
82
+ private readonly invoker: InterceptorInvoker;
83
+ private readonly audit: AuditSink | undefined;
84
+ private readonly log: GatewayLogger;
85
+ private routes = new Map<string, Route>();
86
+
87
+ constructor(opts: AdtlGatewayOptions) {
88
+ this.upstreams = opts.upstreams;
89
+ this.invoker = opts.invoker;
90
+ this.sessionId = opts.sessionId ?? randomUUID();
91
+ this.audit = opts.audit;
92
+ this.log = opts.logger ?? stderrLogger;
93
+ }
94
+
95
+ /** The downstream MCP server the agent connects to. */
96
+ createServer(): Server {
97
+ const server = new Server(
98
+ { name: "adtl-gateway", version: GATEWAY_VERSION },
99
+ {
100
+ capabilities: { tools: {} },
101
+ instructions:
102
+ "Tools from several MCP servers, named <server>__<tool>. ADTL checks each call against trust labels; " +
103
+ "if a call is stopped, the result explains why.",
104
+ },
105
+ );
106
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.listTools() }));
107
+ server.setRequestHandler(CallToolRequestSchema, async (req) => this.callTool(req.params.name, req.params.arguments ?? {}));
108
+ return server;
109
+ }
110
+
111
+ async listTools(): Promise<Tool[]> {
112
+ const routes = new Map<string, Route>();
113
+ const tools: Tool[] = [];
114
+ for (const [upstream, client] of this.upstreams) {
115
+ let cursor: string | undefined;
116
+ try {
117
+ do {
118
+ const page = await client.listTools(cursor ? { cursor } : {});
119
+ for (const t of page.tools) {
120
+ const name = `${upstream}${TOOL_SEPARATOR}${t.name}`;
121
+ routes.set(name, { upstream, tool: t.name, ...(t.annotations ? { annotations: t.annotations } : {}) });
122
+ tools.push({ ...t, name });
123
+ }
124
+ cursor = page.nextCursor;
125
+ } while (cursor);
126
+ } catch (err) {
127
+ this.log.warn(`tools/list failed for ${upstream}: ${(err as Error).message}`);
128
+ }
129
+ }
130
+ this.routes = routes;
131
+ return tools;
132
+ }
133
+
134
+ async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
135
+ const started = performance.now();
136
+ let route = this.routes.get(name);
137
+ if (!route) {
138
+ await this.listTools();
139
+ route = this.routes.get(name);
140
+ }
141
+ if (!route) return textError(`Unknown tool: ${name}`, { reason: "unknown_tool" });
142
+
143
+ const client = this.upstreams.get(route.upstream)!;
144
+ const context: InvocationContext = {
145
+ sessionId: this.sessionId,
146
+ timestamp: new Date().toISOString(),
147
+ principal: { type: "anonymous" },
148
+ };
149
+ const config: Record<string, unknown> = {
150
+ server: route.upstream,
151
+ ...(route.annotations ? { toolAnnotations: route.annotations } : {}),
152
+ };
153
+ const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
154
+ const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
155
+ this.audit?.write({ ts: context.timestamp!, ...base, ...entry, durationMs: Math.round(performance.now() - started) });
156
+
157
+ // 1. Request phase: adtl.policy decides whether the call may run.
158
+ const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
159
+ const req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
160
+ const policy = infoOf(req, "adtl.policy");
161
+ if (req.blocked) {
162
+ finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel, warnings: req.warnings });
163
+ this.log.info(`${req.blocked.decision} ${name}: ${req.blocked.reason}`);
164
+ return textError(blockedMessage(req.blocked), { ...req.blocked, callHash: policy.callHash });
165
+ }
166
+ const forwarded = (req.payload as typeof requestPayload).params;
167
+ const decision = policy["adtl/decision"] as string | undefined;
168
+ const audit = this.invoker.modeOverride === "audit";
169
+ // Only set in audit mode, for calls the policy would have stopped.
170
+ const wouldStop =
171
+ audit && decision !== undefined && decision !== "allow"
172
+ ? { reason: policy.reason as string | undefined, matched: policy.matched }
173
+ : undefined;
174
+ if (wouldStop) this.log.info(`audit: would ${decision} ${name}: ${wouldStop.reason}`);
175
+
176
+ // 2. Forward to the upstream server.
177
+ let result: CallToolResult;
178
+ try {
179
+ result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments })) as CallToolResult;
180
+ } catch (err) {
181
+ const reason = (err as Error).message;
182
+ finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
183
+ return textError(`The ${route.upstream} server returned an error: ${reason}`, { reason: "upstream_error" });
184
+ }
185
+
186
+ // 3. Response phase: adtl.label labels the result and records it in the session.
187
+ const res = await this.invoker.runResponse({
188
+ event: "tools/call",
189
+ payload: { method: "tools/call", params: forwarded, result },
190
+ config,
191
+ context,
192
+ });
193
+ if (res.blocked) {
194
+ // Fail closed: an unlabeled result must not reach the agent.
195
+ finish({ outcome: "blocked", decision: res.blocked.decision, reason: res.blocked.reason, callHash: policy.callHash, warnings: [...req.warnings, ...res.warnings] });
196
+ this.log.warn(`result of ${name} withheld: ${res.blocked.reason}`);
197
+ return textError(`ADTL could not label the result of this call, so it was withheld: ${res.blocked.reason}`, { ...res.blocked });
198
+ }
199
+ const label = infoOf(res, "adtl.label");
200
+ finish({
201
+ outcome: "forwarded",
202
+ // Missing only if adtl.policy failed and was allowed to (audit mode); see warnings.
203
+ ...(decision ? { decision } : {}),
204
+ ...(wouldStop ?? {}),
205
+ ...(audit ? { mode: "audit" as const } : {}),
206
+ callHash: policy.callHash,
207
+ label: label.label,
208
+ sessionLabel: label.sessionLabel,
209
+ warnings: [...req.warnings, ...res.warnings],
210
+ });
211
+ return (res.payload as { result: CallToolResult }).result;
212
+ }
213
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./config.js";
2
+ export * from "./invoker.js";
3
+ export * from "./audit.js";
4
+ export * from "./gateway.js";
5
+ export * from "./startup.js";
package/src/invoker.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Runs interceptors in the order SEP-2624 requires:
3
+ *
4
+ * - Sending (request phase, gateway → upstream): Mutate (sequential, by
5
+ * priorityHint then name) → Validate (parallel).
6
+ * - Receiving (response phase, upstream → gateway): Validate (parallel) →
7
+ * Mutate (sequential).
8
+ *
9
+ * Only `severity: "error"` blocks. Audit-mode interceptors never block, and
10
+ * their mutations are not applied. An interceptor that fails (error or
11
+ * timeout) blocks the message unless it declares `failOpen: true`.
12
+ *
13
+ * The invoker may override every interceptor's `mode`, as SEP-2624 allows
14
+ * (e.g. audit in staging, active in production).
15
+ */
16
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
+ import {
18
+ DECISION_INFO_KEY,
19
+ INVOKE_METHOD,
20
+ LIST_METHOD,
21
+ type InterceptorDefinition,
22
+ type InterceptorResult,
23
+ type InvocationContext,
24
+ type MutationResult,
25
+ type Phase,
26
+ type ValidationResult,
27
+ } from "@adtl/interceptors";
28
+ import type { Decision } from "@adtl/labels";
29
+ import { z } from "zod";
30
+
31
+ const AnyResult = z.object({}).passthrough();
32
+
33
+ type Loose = Record<string, unknown>;
34
+
35
+ /** Untyped request: interceptor methods are extension methods the SDK does not type. */
36
+ function request(client: Client, method: string, params: unknown, timeout: number): Promise<Loose> {
37
+ const r = client.request as unknown as (req: unknown, schema: unknown, opts: unknown) => Promise<Loose>;
38
+ return r.call(client, { method, params }, AnyResult, { timeout });
39
+ }
40
+
41
+ export type InterceptorMode = NonNullable<InterceptorDefinition["mode"]>;
42
+
43
+ export interface BoundInterceptor {
44
+ client: Client;
45
+ definition: InterceptorDefinition;
46
+ }
47
+
48
+ export interface Blocked {
49
+ interceptor: string;
50
+ decision: Decision;
51
+ reason: string;
52
+ }
53
+
54
+ export interface ChainOutcome {
55
+ payload: unknown;
56
+ blocked?: Blocked;
57
+ results: InterceptorResult[];
58
+ /** Interceptors that failed but were allowed to (failOpen or audit mode). */
59
+ warnings: string[];
60
+ }
61
+
62
+ export interface InvokeOptions {
63
+ event: string;
64
+ payload: unknown;
65
+ config: Record<string, unknown>;
66
+ context: InvocationContext;
67
+ }
68
+
69
+ function priority(def: InterceptorDefinition, phase: Phase): number {
70
+ const p = def.priorityHint;
71
+ if (p === undefined) return 0;
72
+ if (typeof p === "number") return p;
73
+ return p[phase] ?? 0;
74
+ }
75
+
76
+ function hooks(def: InterceptorDefinition, event: string, phase: Phase): boolean {
77
+ return def.hooks.some((h) => h.phase === phase && (h.events.includes(event) || h.events.includes("*")));
78
+ }
79
+
80
+ export class InterceptorInvoker {
81
+ constructor(
82
+ private readonly interceptors: BoundInterceptor[],
83
+ private readonly timeoutMs = 5000,
84
+ /** Overrides the mode every interceptor declares. */
85
+ readonly modeOverride?: InterceptorMode,
86
+ ) {}
87
+
88
+ /** Discover the interceptors an interceptor server offers. */
89
+ static async discover(client: Client): Promise<BoundInterceptor[]> {
90
+ const res = await request(client, LIST_METHOD, {}, 10_000);
91
+ const defs = (res.interceptors ?? []) as InterceptorDefinition[];
92
+ return defs.map((definition) => ({ client, definition }));
93
+ }
94
+
95
+ get definitions(): InterceptorDefinition[] {
96
+ return this.interceptors.map((i) => i.definition);
97
+ }
98
+
99
+ async runRequest(opts: InvokeOptions): Promise<ChainOutcome> {
100
+ const out: ChainOutcome = { payload: opts.payload, results: [], warnings: [] };
101
+ if (!(await this.mutate("request", opts, out))) return out;
102
+ await this.validate("request", opts, out);
103
+ return out;
104
+ }
105
+
106
+ async runResponse(opts: InvokeOptions): Promise<ChainOutcome> {
107
+ const out: ChainOutcome = { payload: opts.payload, results: [], warnings: [] };
108
+ if (!(await this.validate("response", opts, out))) return out;
109
+ await this.mutate("response", opts, out);
110
+ return out;
111
+ }
112
+
113
+ private isAudit(i: BoundInterceptor): boolean {
114
+ return (this.modeOverride ?? i.definition.mode) === "audit";
115
+ }
116
+
117
+ private select(type: InterceptorDefinition["type"], event: string, phase: Phase): BoundInterceptor[] {
118
+ return this.interceptors.filter((i) => i.definition.type === type && hooks(i.definition, event, phase));
119
+ }
120
+
121
+ private async invoke(i: BoundInterceptor, phase: Phase, opts: InvokeOptions, payload: unknown): Promise<InterceptorResult> {
122
+ const res = await request(
123
+ i.client,
124
+ INVOKE_METHOD,
125
+ { name: i.definition.name, event: opts.event, phase, payload, config: opts.config, timeoutMs: this.timeoutMs, context: opts.context },
126
+ this.timeoutMs,
127
+ );
128
+ return res as unknown as InterceptorResult;
129
+ }
130
+
131
+ /** Handle an interceptor failure. Returns false if the chain must stop. */
132
+ private failed(i: BoundInterceptor, err: unknown, out: ChainOutcome): boolean {
133
+ const msg = `${i.definition.name} failed: ${err instanceof Error ? err.message : String(err)}`;
134
+ if (this.isAudit(i) || i.definition.failOpen === true) {
135
+ out.warnings.push(msg);
136
+ return true;
137
+ }
138
+ out.blocked = { interceptor: i.definition.name, decision: "block", reason: msg };
139
+ return false;
140
+ }
141
+
142
+ /** Sequential mutations, atomic: on failure the original payload is kept. */
143
+ private async mutate(phase: Phase, opts: InvokeOptions, out: ChainOutcome): Promise<boolean> {
144
+ const chain = this.select("mutation", opts.event, phase).sort(
145
+ (a, b) => priority(a.definition, phase) - priority(b.definition, phase) || a.definition.name.localeCompare(b.definition.name),
146
+ );
147
+ let payload = out.payload;
148
+ for (const i of chain) {
149
+ let r: MutationResult;
150
+ try {
151
+ r = (await this.invoke(i, phase, opts, payload)) as MutationResult;
152
+ } catch (err) {
153
+ if (!this.failed(i, err, out)) return false;
154
+ continue;
155
+ }
156
+ out.results.push(r);
157
+ if (!this.isAudit(i) && r.modified) payload = r.payload;
158
+ }
159
+ out.payload = payload;
160
+ return true;
161
+ }
162
+
163
+ /** Parallel validations. Only severity "error" blocks. */
164
+ private async validate(phase: Phase, opts: InvokeOptions, out: ChainOutcome): Promise<boolean> {
165
+ const chain = this.select("validation", opts.event, phase);
166
+ const settled = await Promise.allSettled(chain.map((i) => this.invoke(i, phase, opts, out.payload)));
167
+ let ok = true;
168
+ settled.forEach((s, idx) => {
169
+ const i = chain[idx]!;
170
+ if (s.status === "rejected") {
171
+ if (!this.failed(i, s.reason, out)) ok = false;
172
+ return;
173
+ }
174
+ const r = s.value as ValidationResult;
175
+ out.results.push(r);
176
+ if (r.valid || r.severity !== "error" || this.isAudit(i)) return;
177
+ if (!out.blocked) {
178
+ const decision = (r.info?.[DECISION_INFO_KEY] as Decision | undefined) ?? "block";
179
+ const reason = r.messages?.map((m) => m.message).join("; ") || `${i.definition.name} rejected the call`;
180
+ out.blocked = { interceptor: i.definition.name, decision, reason };
181
+ }
182
+ ok = false;
183
+ });
184
+ return ok;
185
+ }
186
+ }
package/src/startup.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Build a running gateway from an adtl.yaml file: start the interceptor
3
+ * server, connect every upstream, and check that ADTL's interceptors are there.
4
+ */
5
+ import { readFile } from "node:fs/promises";
6
+ import { dirname, isAbsolute, resolve } from "node:path";
7
+ import { createRequire } from "node:module";
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
10
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
+ import { INTERCEPTORS_EXTENSION, LABEL_INTERCEPTOR, POLICY_INTERCEPTOR, parseConfigText } from "@adtl/interceptors";
12
+ import type { AdtlConfig } from "@adtl/labels";
13
+ import { parse as parseYaml } from "yaml";
14
+ import { ZodError } from "zod";
15
+ import { JsonlAuditLog } from "./audit.js";
16
+ import {
17
+ GatewayConfigSchema,
18
+ expandUpstream,
19
+ isHttpUpstream,
20
+ type GatewayConfig,
21
+ type StdioUpstream,
22
+ type Upstream,
23
+ } from "./config.js";
24
+ import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
25
+ import { InterceptorInvoker } from "./invoker.js";
26
+
27
+ export class GatewayConfigError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = "GatewayConfigError";
31
+ }
32
+ }
33
+
34
+ export interface LoadedConfig {
35
+ path: string;
36
+ labels: AdtlConfig;
37
+ gateway: GatewayConfig;
38
+ }
39
+
40
+ export async function loadGatewayConfig(path: string): Promise<LoadedConfig> {
41
+ const abs = resolve(path);
42
+ let text: string;
43
+ try {
44
+ text = await readFile(abs, "utf8");
45
+ } catch (err) {
46
+ throw new GatewayConfigError(`Cannot read config file ${abs}: ${(err as Error).message}`);
47
+ }
48
+ let labels: AdtlConfig;
49
+ try {
50
+ labels = parseConfigText(text, abs);
51
+ } catch (err) {
52
+ throw new GatewayConfigError((err as Error).message);
53
+ }
54
+ const raw = (parseYaml(text) ?? {}) as { gateway?: unknown };
55
+ if (raw.gateway === undefined) {
56
+ throw new GatewayConfigError(`${abs}: missing "gateway:" section (at least one upstream under gateway.upstreams)`);
57
+ }
58
+ try {
59
+ return { path: abs, labels, gateway: GatewayConfigSchema.parse(raw.gateway) };
60
+ } catch (err) {
61
+ if (err instanceof ZodError) {
62
+ const problems = err.issues.map((i) => ` - gateway.${i.path.join(".")}: ${i.message}`).join("\n");
63
+ throw new GatewayConfigError(`${abs}: invalid gateway config\n${problems}`);
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+
69
+ /** Path of the bundled adtl-interceptors CLI. */
70
+ export function bundledInterceptorsCli(): string {
71
+ // Resolve via package.json: works in plain Node and under tools that lack import.meta.resolve.
72
+ const pkg = createRequire(import.meta.url).resolve("@adtl/interceptors/package.json");
73
+ return resolve(dirname(pkg), "dist", "cli.js");
74
+ }
75
+
76
+ function stdioTransport(u: StdioUpstream, baseDir: string): StdioClientTransport {
77
+ return new StdioClientTransport({
78
+ command: u.command,
79
+ args: u.args,
80
+ // The SDK passes only `env` when given, which would drop PATH; start from its safe defaults.
81
+ env: { ...getDefaultEnvironment(), ...u.env },
82
+ ...(u.cwd !== undefined ? { cwd: isAbsolute(u.cwd) ? u.cwd : resolve(baseDir, u.cwd) } : {}),
83
+ stderr: "inherit",
84
+ });
85
+ }
86
+
87
+ async function connect(id: string, u: Upstream, baseDir: string): Promise<Client> {
88
+ const client = new Client({ name: `adtl-gateway→${id}`, version: GATEWAY_VERSION });
89
+ const transport = isHttpUpstream(u)
90
+ ? new StreamableHTTPClientTransport(new URL(u.url), { requestInit: { headers: u.headers } })
91
+ : stdioTransport(u, baseDir);
92
+ await client.connect(transport);
93
+ return client;
94
+ }
95
+
96
+ export interface StartedGateway {
97
+ gateway: AdtlGateway;
98
+ close(): Promise<void>;
99
+ }
100
+
101
+ export async function startGateway(
102
+ loaded: LoadedConfig,
103
+ opts: { env?: NodeJS.ProcessEnv; logger?: GatewayLogger } = {},
104
+ ): Promise<StartedGateway> {
105
+ const env = opts.env ?? process.env;
106
+ const baseDir = dirname(loaded.path);
107
+ const log = opts.logger ?? { info: (m: string) => console.error(`[adtl-gateway] ${m}`), warn: (m: string) => console.error(`[adtl-gateway] warning: ${m}`) };
108
+ const clients: Client[] = [];
109
+ const closeAll = () => Promise.allSettled(clients.map((c) => c.close())).then(() => undefined);
110
+
111
+ try {
112
+ // 1. Interceptor server (out of process, per the Interceptors WG).
113
+ const icfg: StdioUpstream = loaded.gateway.interceptors
114
+ ? expandUpstream("interceptors", loaded.gateway.interceptors, env)
115
+ : { command: process.execPath, args: [bundledInterceptorsCli(), "--config", loaded.path], env: {} };
116
+ const interceptors = await connect("interceptors", icfg, baseDir);
117
+ clients.push(interceptors);
118
+ if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
119
+ throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
120
+ }
121
+ const bound = await InterceptorInvoker.discover(interceptors);
122
+ const names = bound.map((b) => b.definition.name);
123
+ for (const required of [POLICY_INTERCEPTOR, LABEL_INTERCEPTOR]) {
124
+ if (!names.includes(required)) {
125
+ throw new GatewayConfigError(`The interceptor server does not offer ${required}; refusing to start without it`);
126
+ }
127
+ }
128
+ const audit = loaded.gateway.mode === "audit";
129
+ const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined);
130
+ log.info(`interceptors: ${names.join(", ")}`);
131
+ if (audit) log.warn("audit mode: calls are logged but never stopped or changed");
132
+
133
+ // 2. Upstream servers. One failing upstream does not stop the others.
134
+ const upstreams = new Map<string, Client>();
135
+ const ids = Object.keys(loaded.gateway.upstreams);
136
+ const settled = await Promise.allSettled(
137
+ ids.map((id) => connect(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir)),
138
+ );
139
+ settled.forEach((s, i) => {
140
+ const id = ids[i]!;
141
+ if (s.status === "fulfilled") {
142
+ clients.push(s.value);
143
+ upstreams.set(id, s.value);
144
+ if (!loaded.labels.servers[id]) {
145
+ log.warn(`upstream "${id}" has no entry under servers: in the label config; its results get the default label`);
146
+ }
147
+ } else {
148
+ log.warn(`could not connect to upstream "${id}": ${(s.reason as Error).message}`);
149
+ }
150
+ });
151
+ if (upstreams.size === 0) throw new GatewayConfigError("No upstream server could be connected");
152
+ log.info(`upstreams: ${[...upstreams.keys()].join(", ")}`);
153
+
154
+ const auditLog = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
155
+ const gateway = new AdtlGateway({ upstreams, invoker, ...(auditLog ? { audit: auditLog } : {}), logger: log });
156
+ return { gateway, close: closeAll };
157
+ } catch (err) {
158
+ await closeAll();
159
+ throw err;
160
+ }
161
+ }