@adtl/gateway 0.0.0-stage → 0.1.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 +201 -0
- package/README.md +102 -2
- package/dist/audit.d.ts +30 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +25 -0
- package/dist/audit.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +76 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +199 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +90 -0
- package/dist/config.js.map +1 -0
- package/dist/gateway.d.ts +34 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +151 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/invoker.d.ts +56 -0
- package/dist/invoker.d.ts.map +1 -0
- package/dist/invoker.js +114 -0
- package/dist/invoker.js.map +1 -0
- package/dist/startup.d.ts +23 -0
- package/dist/startup.d.ts.map +1 -0
- package/dist/startup.js +133 -0
- package/dist/startup.js.map +1 -0
- package/package.json +59 -4
- package/src/audit.ts +47 -0
- package/src/cli.ts +73 -0
- package/src/config.ts +100 -0
- package/src/gateway.ts +198 -0
- package/src/index.ts +5 -0
- package/src/invoker.ts +175 -0
- package/src/startup.ts +159 -0
package/src/invoker.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import {
|
|
15
|
+
DECISION_INFO_KEY,
|
|
16
|
+
INVOKE_METHOD,
|
|
17
|
+
LIST_METHOD,
|
|
18
|
+
type InterceptorDefinition,
|
|
19
|
+
type InterceptorResult,
|
|
20
|
+
type InvocationContext,
|
|
21
|
+
type MutationResult,
|
|
22
|
+
type Phase,
|
|
23
|
+
type ValidationResult,
|
|
24
|
+
} from "@adtl/interceptors";
|
|
25
|
+
import type { Decision } from "@adtl/labels";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
|
|
28
|
+
const AnyResult = z.object({}).passthrough();
|
|
29
|
+
|
|
30
|
+
type Loose = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
/** Untyped request: interceptor methods are extension methods the SDK does not type. */
|
|
33
|
+
function request(client: Client, method: string, params: unknown, timeout: number): Promise<Loose> {
|
|
34
|
+
const r = client.request as unknown as (req: unknown, schema: unknown, opts: unknown) => Promise<Loose>;
|
|
35
|
+
return r.call(client, { method, params }, AnyResult, { timeout });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BoundInterceptor {
|
|
39
|
+
client: Client;
|
|
40
|
+
definition: InterceptorDefinition;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Blocked {
|
|
44
|
+
interceptor: string;
|
|
45
|
+
decision: Decision;
|
|
46
|
+
reason: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ChainOutcome {
|
|
50
|
+
payload: unknown;
|
|
51
|
+
blocked?: Blocked;
|
|
52
|
+
results: InterceptorResult[];
|
|
53
|
+
/** Interceptors that failed but were allowed to (failOpen or audit mode). */
|
|
54
|
+
warnings: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface InvokeOptions {
|
|
58
|
+
event: string;
|
|
59
|
+
payload: unknown;
|
|
60
|
+
config: Record<string, unknown>;
|
|
61
|
+
context: InvocationContext;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function priority(def: InterceptorDefinition, phase: Phase): number {
|
|
65
|
+
const p = def.priorityHint;
|
|
66
|
+
if (p === undefined) return 0;
|
|
67
|
+
if (typeof p === "number") return p;
|
|
68
|
+
return p[phase] ?? 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hooks(def: InterceptorDefinition, event: string, phase: Phase): boolean {
|
|
72
|
+
return def.hooks.some((h) => h.phase === phase && (h.events.includes(event) || h.events.includes("*")));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class InterceptorInvoker {
|
|
76
|
+
constructor(
|
|
77
|
+
private readonly interceptors: BoundInterceptor[],
|
|
78
|
+
private readonly timeoutMs = 5000,
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
/** Discover the interceptors an interceptor server offers. */
|
|
82
|
+
static async discover(client: Client): Promise<BoundInterceptor[]> {
|
|
83
|
+
const res = await request(client, LIST_METHOD, {}, 10_000);
|
|
84
|
+
const defs = (res.interceptors ?? []) as InterceptorDefinition[];
|
|
85
|
+
return defs.map((definition) => ({ client, definition }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get definitions(): InterceptorDefinition[] {
|
|
89
|
+
return this.interceptors.map((i) => i.definition);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async runRequest(opts: InvokeOptions): Promise<ChainOutcome> {
|
|
93
|
+
const out: ChainOutcome = { payload: opts.payload, results: [], warnings: [] };
|
|
94
|
+
if (!(await this.mutate("request", opts, out))) return out;
|
|
95
|
+
await this.validate("request", opts, out);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async runResponse(opts: InvokeOptions): Promise<ChainOutcome> {
|
|
100
|
+
const out: ChainOutcome = { payload: opts.payload, results: [], warnings: [] };
|
|
101
|
+
if (!(await this.validate("response", opts, out))) return out;
|
|
102
|
+
await this.mutate("response", opts, out);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private select(type: InterceptorDefinition["type"], event: string, phase: Phase): BoundInterceptor[] {
|
|
107
|
+
return this.interceptors.filter((i) => i.definition.type === type && hooks(i.definition, event, phase));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async invoke(i: BoundInterceptor, phase: Phase, opts: InvokeOptions, payload: unknown): Promise<InterceptorResult> {
|
|
111
|
+
const res = await request(
|
|
112
|
+
i.client,
|
|
113
|
+
INVOKE_METHOD,
|
|
114
|
+
{ name: i.definition.name, event: opts.event, phase, payload, config: opts.config, timeoutMs: this.timeoutMs, context: opts.context },
|
|
115
|
+
this.timeoutMs,
|
|
116
|
+
);
|
|
117
|
+
return res as unknown as InterceptorResult;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Handle an interceptor failure. Returns false if the chain must stop. */
|
|
121
|
+
private failed(i: BoundInterceptor, err: unknown, out: ChainOutcome): boolean {
|
|
122
|
+
const msg = `${i.definition.name} failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
123
|
+
if (i.definition.mode === "audit" || i.definition.failOpen === true) {
|
|
124
|
+
out.warnings.push(msg);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
out.blocked = { interceptor: i.definition.name, decision: "block", reason: msg };
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Sequential mutations, atomic: on failure the original payload is kept. */
|
|
132
|
+
private async mutate(phase: Phase, opts: InvokeOptions, out: ChainOutcome): Promise<boolean> {
|
|
133
|
+
const chain = this.select("mutation", opts.event, phase).sort(
|
|
134
|
+
(a, b) => priority(a.definition, phase) - priority(b.definition, phase) || a.definition.name.localeCompare(b.definition.name),
|
|
135
|
+
);
|
|
136
|
+
let payload = out.payload;
|
|
137
|
+
for (const i of chain) {
|
|
138
|
+
let r: MutationResult;
|
|
139
|
+
try {
|
|
140
|
+
r = (await this.invoke(i, phase, opts, payload)) as MutationResult;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (!this.failed(i, err, out)) return false;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
out.results.push(r);
|
|
146
|
+
if (i.definition.mode !== "audit" && r.modified) payload = r.payload;
|
|
147
|
+
}
|
|
148
|
+
out.payload = payload;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Parallel validations. Only severity "error" blocks. */
|
|
153
|
+
private async validate(phase: Phase, opts: InvokeOptions, out: ChainOutcome): Promise<boolean> {
|
|
154
|
+
const chain = this.select("validation", opts.event, phase);
|
|
155
|
+
const settled = await Promise.allSettled(chain.map((i) => this.invoke(i, phase, opts, out.payload)));
|
|
156
|
+
let ok = true;
|
|
157
|
+
settled.forEach((s, idx) => {
|
|
158
|
+
const i = chain[idx]!;
|
|
159
|
+
if (s.status === "rejected") {
|
|
160
|
+
if (!this.failed(i, s.reason, out)) ok = false;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const r = s.value as ValidationResult;
|
|
164
|
+
out.results.push(r);
|
|
165
|
+
if (r.valid || r.severity !== "error" || i.definition.mode === "audit") return;
|
|
166
|
+
if (!out.blocked) {
|
|
167
|
+
const decision = (r.info?.[DECISION_INFO_KEY] as Decision | undefined) ?? "block";
|
|
168
|
+
const reason = r.messages?.map((m) => m.message).join("; ") || `${i.definition.name} rejected the call`;
|
|
169
|
+
out.blocked = { interceptor: i.definition.name, decision, reason };
|
|
170
|
+
}
|
|
171
|
+
ok = false;
|
|
172
|
+
});
|
|
173
|
+
return ok;
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/startup.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
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 invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs);
|
|
129
|
+
log.info(`interceptors: ${names.join(", ")}`);
|
|
130
|
+
|
|
131
|
+
// 2. Upstream servers. One failing upstream does not stop the others.
|
|
132
|
+
const upstreams = new Map<string, Client>();
|
|
133
|
+
const ids = Object.keys(loaded.gateway.upstreams);
|
|
134
|
+
const settled = await Promise.allSettled(
|
|
135
|
+
ids.map((id) => connect(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir)),
|
|
136
|
+
);
|
|
137
|
+
settled.forEach((s, i) => {
|
|
138
|
+
const id = ids[i]!;
|
|
139
|
+
if (s.status === "fulfilled") {
|
|
140
|
+
clients.push(s.value);
|
|
141
|
+
upstreams.set(id, s.value);
|
|
142
|
+
if (!loaded.labels.servers[id]) {
|
|
143
|
+
log.warn(`upstream "${id}" has no entry under servers: in the label config; its results get the default label`);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
log.warn(`could not connect to upstream "${id}": ${(s.reason as Error).message}`);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
if (upstreams.size === 0) throw new GatewayConfigError("No upstream server could be connected");
|
|
150
|
+
log.info(`upstreams: ${[...upstreams.keys()].join(", ")}`);
|
|
151
|
+
|
|
152
|
+
const audit = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
|
|
153
|
+
const gateway = new AdtlGateway({ upstreams, invoker, ...(audit ? { audit } : {}), logger: log });
|
|
154
|
+
return { gateway, close: closeAll };
|
|
155
|
+
} catch (err) {
|
|
156
|
+
await closeAll();
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|