@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/dist/startup.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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 { parse as parseYaml } from "yaml";
|
|
13
|
+
import { ZodError } from "zod";
|
|
14
|
+
import { JsonlAuditLog } from "./audit.js";
|
|
15
|
+
import { GatewayConfigSchema, expandUpstream, isHttpUpstream, } from "./config.js";
|
|
16
|
+
import { AdtlGateway, GATEWAY_VERSION } from "./gateway.js";
|
|
17
|
+
import { InterceptorInvoker } from "./invoker.js";
|
|
18
|
+
export class GatewayConfigError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "GatewayConfigError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function loadGatewayConfig(path) {
|
|
25
|
+
const abs = resolve(path);
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = await readFile(abs, "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw new GatewayConfigError(`Cannot read config file ${abs}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
let labels;
|
|
34
|
+
try {
|
|
35
|
+
labels = parseConfigText(text, abs);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new GatewayConfigError(err.message);
|
|
39
|
+
}
|
|
40
|
+
const raw = (parseYaml(text) ?? {});
|
|
41
|
+
if (raw.gateway === undefined) {
|
|
42
|
+
throw new GatewayConfigError(`${abs}: missing "gateway:" section (at least one upstream under gateway.upstreams)`);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return { path: abs, labels, gateway: GatewayConfigSchema.parse(raw.gateway) };
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof ZodError) {
|
|
49
|
+
const problems = err.issues.map((i) => ` - gateway.${i.path.join(".")}: ${i.message}`).join("\n");
|
|
50
|
+
throw new GatewayConfigError(`${abs}: invalid gateway config\n${problems}`);
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Path of the bundled adtl-interceptors CLI. */
|
|
56
|
+
export function bundledInterceptorsCli() {
|
|
57
|
+
// Resolve via package.json: works in plain Node and under tools that lack import.meta.resolve.
|
|
58
|
+
const pkg = createRequire(import.meta.url).resolve("@adtl/interceptors/package.json");
|
|
59
|
+
return resolve(dirname(pkg), "dist", "cli.js");
|
|
60
|
+
}
|
|
61
|
+
function stdioTransport(u, baseDir) {
|
|
62
|
+
return new StdioClientTransport({
|
|
63
|
+
command: u.command,
|
|
64
|
+
args: u.args,
|
|
65
|
+
// The SDK passes only `env` when given, which would drop PATH; start from its safe defaults.
|
|
66
|
+
env: { ...getDefaultEnvironment(), ...u.env },
|
|
67
|
+
...(u.cwd !== undefined ? { cwd: isAbsolute(u.cwd) ? u.cwd : resolve(baseDir, u.cwd) } : {}),
|
|
68
|
+
stderr: "inherit",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async function connect(id, u, baseDir) {
|
|
72
|
+
const client = new Client({ name: `adtl-gateway→${id}`, version: GATEWAY_VERSION });
|
|
73
|
+
const transport = isHttpUpstream(u)
|
|
74
|
+
? new StreamableHTTPClientTransport(new URL(u.url), { requestInit: { headers: u.headers } })
|
|
75
|
+
: stdioTransport(u, baseDir);
|
|
76
|
+
await client.connect(transport);
|
|
77
|
+
return client;
|
|
78
|
+
}
|
|
79
|
+
export async function startGateway(loaded, opts = {}) {
|
|
80
|
+
const env = opts.env ?? process.env;
|
|
81
|
+
const baseDir = dirname(loaded.path);
|
|
82
|
+
const log = opts.logger ?? { info: (m) => console.error(`[adtl-gateway] ${m}`), warn: (m) => console.error(`[adtl-gateway] warning: ${m}`) };
|
|
83
|
+
const clients = [];
|
|
84
|
+
const closeAll = () => Promise.allSettled(clients.map((c) => c.close())).then(() => undefined);
|
|
85
|
+
try {
|
|
86
|
+
// 1. Interceptor server (out of process, per the Interceptors WG).
|
|
87
|
+
const icfg = loaded.gateway.interceptors
|
|
88
|
+
? expandUpstream("interceptors", loaded.gateway.interceptors, env)
|
|
89
|
+
: { command: process.execPath, args: [bundledInterceptorsCli(), "--config", loaded.path], env: {} };
|
|
90
|
+
const interceptors = await connect("interceptors", icfg, baseDir);
|
|
91
|
+
clients.push(interceptors);
|
|
92
|
+
if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
|
|
93
|
+
throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
|
|
94
|
+
}
|
|
95
|
+
const bound = await InterceptorInvoker.discover(interceptors);
|
|
96
|
+
const names = bound.map((b) => b.definition.name);
|
|
97
|
+
for (const required of [POLICY_INTERCEPTOR, LABEL_INTERCEPTOR]) {
|
|
98
|
+
if (!names.includes(required)) {
|
|
99
|
+
throw new GatewayConfigError(`The interceptor server does not offer ${required}; refusing to start without it`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs);
|
|
103
|
+
log.info(`interceptors: ${names.join(", ")}`);
|
|
104
|
+
// 2. Upstream servers. One failing upstream does not stop the others.
|
|
105
|
+
const upstreams = new Map();
|
|
106
|
+
const ids = Object.keys(loaded.gateway.upstreams);
|
|
107
|
+
const settled = await Promise.allSettled(ids.map((id) => connect(id, expandUpstream(id, loaded.gateway.upstreams[id], env), baseDir)));
|
|
108
|
+
settled.forEach((s, i) => {
|
|
109
|
+
const id = ids[i];
|
|
110
|
+
if (s.status === "fulfilled") {
|
|
111
|
+
clients.push(s.value);
|
|
112
|
+
upstreams.set(id, s.value);
|
|
113
|
+
if (!loaded.labels.servers[id]) {
|
|
114
|
+
log.warn(`upstream "${id}" has no entry under servers: in the label config; its results get the default label`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
log.warn(`could not connect to upstream "${id}": ${s.reason.message}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (upstreams.size === 0)
|
|
122
|
+
throw new GatewayConfigError("No upstream server could be connected");
|
|
123
|
+
log.info(`upstreams: ${[...upstreams.keys()].join(", ")}`);
|
|
124
|
+
const audit = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
|
|
125
|
+
const gateway = new AdtlGateway({ upstreams, invoker, ...(audit ? { audit } : {}), logger: log });
|
|
126
|
+
return { gateway, close: closeAll };
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
await closeAll();
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=startup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"startup.js","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adtl/gateway",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Experimental: MCP gateway that labels every tool result and blocks risky tool calls using ADTL trust labels",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"mcp-gateway",
|
|
9
|
+
"mcp-proxy",
|
|
10
|
+
"ai-agents",
|
|
11
|
+
"ai-security",
|
|
12
|
+
"prompt-injection",
|
|
13
|
+
"trust-labels"
|
|
14
|
+
],
|
|
15
|
+
"author": "Murthy G",
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"adtl-gateway": "./dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -b",
|
|
37
|
+
"test": "vitest run"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/probita-ai/adtl.git",
|
|
42
|
+
"directory": "packages/gateway"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/probita-ai/adtl/tree/main/packages/gateway#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/probita-ai/adtl/issues"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@adtl/interceptors": "0.1.0",
|
|
50
|
+
"@adtl/labels": "0.1.0",
|
|
51
|
+
"@modelcontextprotocol/sdk": "1.31.0",
|
|
52
|
+
"yaml": "2.9.1",
|
|
53
|
+
"zod": "3.25.76"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only JSONL audit log. One line per tool call.
|
|
3
|
+
*
|
|
4
|
+
* Arguments and results are never written; the call is identified by its
|
|
5
|
+
* hash, so the log can be shared without leaking the data it describes.
|
|
6
|
+
*/
|
|
7
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface AuditEntry {
|
|
11
|
+
ts: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
event: "tools/call";
|
|
14
|
+
server: string;
|
|
15
|
+
tool: string;
|
|
16
|
+
/** Final outcome of the call. */
|
|
17
|
+
outcome: "forwarded" | "blocked" | "upstream_error";
|
|
18
|
+
decision?: string;
|
|
19
|
+
reason?: string;
|
|
20
|
+
matched?: unknown;
|
|
21
|
+
callHash?: unknown;
|
|
22
|
+
label?: unknown;
|
|
23
|
+
sessionLabel?: unknown;
|
|
24
|
+
warnings?: string[];
|
|
25
|
+
durationMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AuditSink {
|
|
29
|
+
write(entry: AuditEntry): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class JsonlAuditLog implements AuditSink {
|
|
33
|
+
constructor(readonly path: string) {
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
write(entry: AuditEntry): void {
|
|
38
|
+
appendFileSync(this.path, `${JSON.stringify(entry)}\n`, "utf8");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class MemoryAuditLog implements AuditSink {
|
|
43
|
+
readonly entries: AuditEntry[] = [];
|
|
44
|
+
write(entry: AuditEntry): void {
|
|
45
|
+
this.entries.push(entry);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* adtl-gateway: an MCP server for your agent that proxies to your real MCP
|
|
4
|
+
* servers through ADTL's interceptors.
|
|
5
|
+
*
|
|
6
|
+
* adtl-gateway --config adtl.yaml
|
|
7
|
+
*
|
|
8
|
+
* Point your MCP client (e.g. Claude Code's .mcp.json) at this command.
|
|
9
|
+
* Logs go to stderr; stdout carries MCP JSON-RPC only.
|
|
10
|
+
*/
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { EnvExpansionError } from "./config.js";
|
|
13
|
+
import { GATEWAY_VERSION } from "./gateway.js";
|
|
14
|
+
import { GatewayConfigError, loadGatewayConfig, startGateway } from "./startup.js";
|
|
15
|
+
|
|
16
|
+
const USAGE = `Usage: adtl-gateway --config <adtl.yaml>
|
|
17
|
+
|
|
18
|
+
Runs an MCP server over stdio that forwards tool calls to the upstream
|
|
19
|
+
servers in the config, checking and labeling each call with ADTL.
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
-c, --config <path> ADTL config file with a gateway: section. Required.
|
|
23
|
+
-v, --version Print the version and exit.
|
|
24
|
+
-h, --help Show this help.`;
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv: string[]): { config?: string; help?: boolean; version?: boolean } {
|
|
27
|
+
const out: { config?: string; help?: boolean; version?: boolean } = {};
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a === "-h" || a === "--help") out.help = true;
|
|
31
|
+
else if (a === "-v" || a === "--version") out.version = true;
|
|
32
|
+
else if (a === "-c" || a === "--config") out.config = argv[++i];
|
|
33
|
+
else if (a?.startsWith("--config=")) out.config = a.slice("--config=".length);
|
|
34
|
+
else throw new GatewayConfigError(`Unknown argument: ${a}\n\n${USAGE}`);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main(): Promise<void> {
|
|
40
|
+
const args = parseArgs(process.argv.slice(2));
|
|
41
|
+
// --help and --version print to stdout and exit; MCP is never served in these modes.
|
|
42
|
+
if (args.help) return void console.log(USAGE);
|
|
43
|
+
if (args.version) return void console.log(GATEWAY_VERSION);
|
|
44
|
+
if (!args.config) throw new GatewayConfigError(`Missing --config\n\n${USAGE}`);
|
|
45
|
+
|
|
46
|
+
const loaded = await loadGatewayConfig(args.config);
|
|
47
|
+
const started = await startGateway(loaded);
|
|
48
|
+
const server = started.gateway.createServer();
|
|
49
|
+
|
|
50
|
+
let closing = false;
|
|
51
|
+
const shutdown = async () => {
|
|
52
|
+
if (closing) return;
|
|
53
|
+
closing = true;
|
|
54
|
+
await server.close().catch(() => undefined);
|
|
55
|
+
await started.close();
|
|
56
|
+
process.exit(0);
|
|
57
|
+
};
|
|
58
|
+
server.onclose = () => void shutdown();
|
|
59
|
+
// The agent closing our stdin means the session is over; don't wait for a signal.
|
|
60
|
+
process.stdin.on("end", () => void shutdown());
|
|
61
|
+
process.stdin.on("close", () => void shutdown());
|
|
62
|
+
process.on("SIGINT", () => void shutdown());
|
|
63
|
+
process.on("SIGTERM", () => void shutdown());
|
|
64
|
+
|
|
65
|
+
await server.connect(new StdioServerTransport());
|
|
66
|
+
console.error(`[adtl-gateway] ${GATEWAY_VERSION} ready (session ${started.gateway.sessionId})`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main().catch((err: unknown) => {
|
|
70
|
+
const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError;
|
|
71
|
+
console.error(known ? `[adtl-gateway] ${(err as Error).message}` : err);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `gateway:` section of adtl.yaml.
|
|
3
|
+
*
|
|
4
|
+
* gateway:
|
|
5
|
+
* audit: ./adtl-audit.jsonl # optional JSONL audit log
|
|
6
|
+
* upstreams:
|
|
7
|
+
* github: # id must match `servers.github` in the label config
|
|
8
|
+
* command: npx
|
|
9
|
+
* args: ["-y", "@modelcontextprotocol/server-github"]
|
|
10
|
+
* env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_TOKEN}" }
|
|
11
|
+
* docs:
|
|
12
|
+
* url: https://example.com/mcp
|
|
13
|
+
* headers: { Authorization: "Bearer ${DOCS_TOKEN}" }
|
|
14
|
+
*
|
|
15
|
+
* `${VAR}` in command, args, env, cwd, url and headers is replaced from the
|
|
16
|
+
* gateway's environment. A missing variable is a config error.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
const UPSTREAM_ID = /^[A-Za-z0-9-]+$/;
|
|
21
|
+
|
|
22
|
+
export const StdioUpstreamSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
command: z.string().min(1),
|
|
25
|
+
args: z.array(z.string()).default([]),
|
|
26
|
+
env: z.record(z.string()).default({}),
|
|
27
|
+
cwd: z.string().optional(),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
|
|
31
|
+
export const HttpUpstreamSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
url: z.string().min(1),
|
|
34
|
+
headers: z.record(z.string()).default({}),
|
|
35
|
+
})
|
|
36
|
+
.strict();
|
|
37
|
+
|
|
38
|
+
export const UpstreamSchema = z.union([StdioUpstreamSchema, HttpUpstreamSchema]);
|
|
39
|
+
export type StdioUpstream = z.infer<typeof StdioUpstreamSchema>;
|
|
40
|
+
export type HttpUpstream = z.infer<typeof HttpUpstreamSchema>;
|
|
41
|
+
export type Upstream = z.infer<typeof UpstreamSchema>;
|
|
42
|
+
|
|
43
|
+
export const GatewayConfigSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
upstreams: z
|
|
46
|
+
.record(UpstreamSchema)
|
|
47
|
+
.refine((u) => Object.keys(u).length > 0, "At least one upstream server is required")
|
|
48
|
+
.refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
|
|
49
|
+
message: "Upstream ids may only contain letters, digits and '-' (they prefix tool names as <id>__<tool>)",
|
|
50
|
+
}),
|
|
51
|
+
/** How to start the interceptor server. Default: the bundled adtl-interceptors with this same config file. */
|
|
52
|
+
interceptors: StdioUpstreamSchema.optional(),
|
|
53
|
+
/** Path of a JSONL audit log. Relative paths are resolved against the config file's directory. */
|
|
54
|
+
audit: z.string().min(1).optional(),
|
|
55
|
+
/** Timeout for each interceptor invocation, in milliseconds. */
|
|
56
|
+
interceptorTimeoutMs: z.number().int().positive().default(5000),
|
|
57
|
+
})
|
|
58
|
+
.strict();
|
|
59
|
+
export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
|
|
60
|
+
|
|
61
|
+
export const TOOL_SEPARATOR = "__";
|
|
62
|
+
|
|
63
|
+
export class EnvExpansionError extends Error {
|
|
64
|
+
constructor(readonly variable: string, where: string) {
|
|
65
|
+
super(`Environment variable ${variable} is not set (used in ${where})`);
|
|
66
|
+
this.name = "EnvExpansionError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Replace `${VAR}` with values from `env`. `$${VAR}` keeps a literal `${VAR}`. */
|
|
71
|
+
export function expandEnv(value: string, env: NodeJS.ProcessEnv, where: string): string {
|
|
72
|
+
return value.replace(/(\$?)\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, escape: string, name: string) => {
|
|
73
|
+
if (escape) return match.slice(1);
|
|
74
|
+
const v = env[name];
|
|
75
|
+
if (v === undefined) throw new EnvExpansionError(name, where);
|
|
76
|
+
return v;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function expandRecord(rec: Record<string, string>, env: NodeJS.ProcessEnv, where: string): Record<string, string> {
|
|
81
|
+
return Object.fromEntries(Object.entries(rec).map(([k, v]) => [k, expandEnv(v, env, `${where}.${k}`)]));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function expandUpstream<T extends Upstream>(id: string, u: T, env: NodeJS.ProcessEnv): T {
|
|
85
|
+
const where = `gateway.upstreams.${id}`;
|
|
86
|
+
if ("url" in u) {
|
|
87
|
+
return { ...u, url: expandEnv(u.url, env, `${where}.url`), headers: expandRecord(u.headers, env, `${where}.headers`) };
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
...u,
|
|
91
|
+
command: expandEnv(u.command, env, `${where}.command`),
|
|
92
|
+
args: u.args.map((a, i) => expandEnv(a, env, `${where}.args[${i}]`)),
|
|
93
|
+
env: expandRecord(u.env, env, `${where}.env`),
|
|
94
|
+
...(u.cwd !== undefined ? { cwd: expandEnv(u.cwd, env, `${where}.cwd`) } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isHttpUpstream(u: Upstream): u is HttpUpstream {
|
|
99
|
+
return "url" in u;
|
|
100
|
+
}
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
15
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
|
+
import {
|
|
17
|
+
CallToolRequestSchema,
|
|
18
|
+
ListToolsRequestSchema,
|
|
19
|
+
type CallToolResult,
|
|
20
|
+
type Tool,
|
|
21
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
22
|
+
import type { InvocationContext } from "@adtl/interceptors";
|
|
23
|
+
import type { AuditEntry, AuditSink } from "./audit.js";
|
|
24
|
+
import { TOOL_SEPARATOR } from "./config.js";
|
|
25
|
+
import type { Blocked, ChainOutcome, InterceptorInvoker } from "./invoker.js";
|
|
26
|
+
|
|
27
|
+
export const GATEWAY_VERSION = "0.1.0";
|
|
28
|
+
|
|
29
|
+
/** `_meta` key on gateway-generated error results describing why a call was stopped. */
|
|
30
|
+
export const GATEWAY_DECISION_KEY = "adtl/gateway";
|
|
31
|
+
|
|
32
|
+
interface Route {
|
|
33
|
+
upstream: string;
|
|
34
|
+
tool: string;
|
|
35
|
+
annotations?: Tool["annotations"];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GatewayLogger {
|
|
39
|
+
info(msg: string): void;
|
|
40
|
+
warn(msg: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stderrLogger: GatewayLogger = {
|
|
44
|
+
info: (m) => console.error(`[adtl-gateway] ${m}`),
|
|
45
|
+
warn: (m) => console.error(`[adtl-gateway] warning: ${m}`),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface AdtlGatewayOptions {
|
|
49
|
+
upstreams: Map<string, Client>;
|
|
50
|
+
invoker: InterceptorInvoker;
|
|
51
|
+
/** One gateway process serves one agent session over stdio. */
|
|
52
|
+
sessionId?: string;
|
|
53
|
+
audit?: AuditSink;
|
|
54
|
+
logger?: GatewayLogger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function textError(text: string, meta: Record<string, unknown>): CallToolResult {
|
|
58
|
+
return { content: [{ type: "text", text }], isError: true, _meta: { [GATEWAY_DECISION_KEY]: meta } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function blockedMessage(b: Blocked): string {
|
|
62
|
+
if (b.decision === "ask") {
|
|
63
|
+
return (
|
|
64
|
+
`ADTL stopped this call because it needs a human's approval: ${b.reason}. ` +
|
|
65
|
+
`This version of ADTL cannot ask for approval yet, so the call was not run. Tell the user what you were trying to do.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return `ADTL blocked this call: ${b.reason}. The call was not run.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function infoOf(outcome: ChainOutcome, interceptor: string): Record<string, unknown> {
|
|
72
|
+
return outcome.results.find((r) => r.interceptor === interceptor)?.info ?? {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class AdtlGateway {
|
|
76
|
+
readonly sessionId: string;
|
|
77
|
+
private readonly upstreams: Map<string, Client>;
|
|
78
|
+
private readonly invoker: InterceptorInvoker;
|
|
79
|
+
private readonly audit: AuditSink | undefined;
|
|
80
|
+
private readonly log: GatewayLogger;
|
|
81
|
+
private routes = new Map<string, Route>();
|
|
82
|
+
|
|
83
|
+
constructor(opts: AdtlGatewayOptions) {
|
|
84
|
+
this.upstreams = opts.upstreams;
|
|
85
|
+
this.invoker = opts.invoker;
|
|
86
|
+
this.sessionId = opts.sessionId ?? randomUUID();
|
|
87
|
+
this.audit = opts.audit;
|
|
88
|
+
this.log = opts.logger ?? stderrLogger;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The downstream MCP server the agent connects to. */
|
|
92
|
+
createServer(): Server {
|
|
93
|
+
const server = new Server(
|
|
94
|
+
{ name: "adtl-gateway", version: GATEWAY_VERSION },
|
|
95
|
+
{
|
|
96
|
+
capabilities: { tools: {} },
|
|
97
|
+
instructions:
|
|
98
|
+
"Tools from several MCP servers, named <server>__<tool>. ADTL checks each call against trust labels; " +
|
|
99
|
+
"if a call is stopped, the result explains why.",
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.listTools() }));
|
|
103
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => this.callTool(req.params.name, req.params.arguments ?? {}));
|
|
104
|
+
return server;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async listTools(): Promise<Tool[]> {
|
|
108
|
+
const routes = new Map<string, Route>();
|
|
109
|
+
const tools: Tool[] = [];
|
|
110
|
+
for (const [upstream, client] of this.upstreams) {
|
|
111
|
+
let cursor: string | undefined;
|
|
112
|
+
try {
|
|
113
|
+
do {
|
|
114
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
115
|
+
for (const t of page.tools) {
|
|
116
|
+
const name = `${upstream}${TOOL_SEPARATOR}${t.name}`;
|
|
117
|
+
routes.set(name, { upstream, tool: t.name, ...(t.annotations ? { annotations: t.annotations } : {}) });
|
|
118
|
+
tools.push({ ...t, name });
|
|
119
|
+
}
|
|
120
|
+
cursor = page.nextCursor;
|
|
121
|
+
} while (cursor);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
this.log.warn(`tools/list failed for ${upstream}: ${(err as Error).message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
this.routes = routes;
|
|
127
|
+
return tools;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
|
|
131
|
+
const started = performance.now();
|
|
132
|
+
let route = this.routes.get(name);
|
|
133
|
+
if (!route) {
|
|
134
|
+
await this.listTools();
|
|
135
|
+
route = this.routes.get(name);
|
|
136
|
+
}
|
|
137
|
+
if (!route) return textError(`Unknown tool: ${name}`, { reason: "unknown_tool" });
|
|
138
|
+
|
|
139
|
+
const client = this.upstreams.get(route.upstream)!;
|
|
140
|
+
const context: InvocationContext = {
|
|
141
|
+
sessionId: this.sessionId,
|
|
142
|
+
timestamp: new Date().toISOString(),
|
|
143
|
+
principal: { type: "anonymous" },
|
|
144
|
+
};
|
|
145
|
+
const config: Record<string, unknown> = {
|
|
146
|
+
server: route.upstream,
|
|
147
|
+
...(route.annotations ? { toolAnnotations: route.annotations } : {}),
|
|
148
|
+
};
|
|
149
|
+
const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
|
|
150
|
+
const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
|
|
151
|
+
this.audit?.write({ ts: context.timestamp!, ...base, ...entry, durationMs: Math.round(performance.now() - started) });
|
|
152
|
+
|
|
153
|
+
// 1. Request phase: adtl.policy decides whether the call may run.
|
|
154
|
+
const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
|
|
155
|
+
const req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
|
|
156
|
+
const policy = infoOf(req, "adtl.policy");
|
|
157
|
+
if (req.blocked) {
|
|
158
|
+
finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel, warnings: req.warnings });
|
|
159
|
+
this.log.info(`${req.blocked.decision} ${name}: ${req.blocked.reason}`);
|
|
160
|
+
return textError(blockedMessage(req.blocked), { ...req.blocked, callHash: policy.callHash });
|
|
161
|
+
}
|
|
162
|
+
const forwarded = (req.payload as typeof requestPayload).params;
|
|
163
|
+
|
|
164
|
+
// 2. Forward to the upstream server.
|
|
165
|
+
let result: CallToolResult;
|
|
166
|
+
try {
|
|
167
|
+
result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments })) as CallToolResult;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const reason = (err as Error).message;
|
|
170
|
+
finish({ outcome: "upstream_error", decision: "allow", reason, callHash: policy.callHash, warnings: req.warnings });
|
|
171
|
+
return textError(`The ${route.upstream} server returned an error: ${reason}`, { reason: "upstream_error" });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 3. Response phase: adtl.label labels the result and records it in the session.
|
|
175
|
+
const res = await this.invoker.runResponse({
|
|
176
|
+
event: "tools/call",
|
|
177
|
+
payload: { method: "tools/call", params: forwarded, result },
|
|
178
|
+
config,
|
|
179
|
+
context,
|
|
180
|
+
});
|
|
181
|
+
if (res.blocked) {
|
|
182
|
+
// Fail closed: an unlabeled result must not reach the agent.
|
|
183
|
+
finish({ outcome: "blocked", decision: res.blocked.decision, reason: res.blocked.reason, callHash: policy.callHash, warnings: [...req.warnings, ...res.warnings] });
|
|
184
|
+
this.log.warn(`result of ${name} withheld: ${res.blocked.reason}`);
|
|
185
|
+
return textError(`ADTL could not label the result of this call, so it was withheld: ${res.blocked.reason}`, { ...res.blocked });
|
|
186
|
+
}
|
|
187
|
+
const label = infoOf(res, "adtl.label");
|
|
188
|
+
finish({
|
|
189
|
+
outcome: "forwarded",
|
|
190
|
+
decision: (policy["adtl/decision"] as string | undefined) ?? "allow",
|
|
191
|
+
callHash: policy.callHash,
|
|
192
|
+
label: label.label,
|
|
193
|
+
sessionLabel: label.sessionLabel,
|
|
194
|
+
warnings: [...req.warnings, ...res.warnings],
|
|
195
|
+
});
|
|
196
|
+
return (res.payload as { result: CallToolResult }).result;
|
|
197
|
+
}
|
|
198
|
+
}
|