@adtl/gateway 0.3.1 → 0.3.2
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/README.md +17 -0
- package/dist/cli.js +44 -14
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +49 -44
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -3
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts +1 -1
- package/dist/gateway.js +1 -1
- package/dist/init/fromMcpJson.d.ts +41 -0
- package/dist/init/fromMcpJson.d.ts.map +1 -0
- package/dist/init/fromMcpJson.js +141 -0
- package/dist/init/fromMcpJson.js.map +1 -0
- package/dist/init/index.d.ts +2 -0
- package/dist/init/index.d.ts.map +1 -1
- package/dist/init/index.js +2 -0
- package/dist/init/index.js.map +1 -1
- package/dist/init/run.d.ts +13 -0
- package/dist/init/run.d.ts.map +1 -1
- package/dist/init/run.js +83 -11
- package/dist/init/run.js.map +1 -1
- package/dist/startup.d.ts +2 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +5 -0
- package/dist/startup.js.map +1 -1
- package/package.json +4 -4
- package/src/cli.ts +36 -14
- package/src/config.ts +9 -3
- package/src/gateway.ts +1 -1
- package/src/init/fromMcpJson.ts +165 -0
- package/src/init/index.ts +2 -0
- package/src/init/run.ts +91 -12
- package/src/startup.ts +6 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `adtl-gateway init --from .mcp.json`: import an MCP client's server list
|
|
3
|
+
* (Claude Code's .mcp.json, or any file with `mcpServers`, such as Claude
|
|
4
|
+
* Desktop's config) into `gateway.upstreams`, and point the client at the
|
|
5
|
+
* gateway instead.
|
|
6
|
+
*
|
|
7
|
+
* - stdio servers keep their command, args and env; HTTP servers their url
|
|
8
|
+
* and headers. `${VAR}` and `${VAR:-default}` work the same in adtl.yaml.
|
|
9
|
+
* - SSE servers are left in the client config: the gateway speaks Streamable HTTP only.
|
|
10
|
+
* - An entry that already runs the gateway is recognized and kept.
|
|
11
|
+
* - Upstreams already in adtl.yaml are never changed.
|
|
12
|
+
*
|
|
13
|
+
* The new client config has the imported servers replaced by one entry that
|
|
14
|
+
* runs the gateway; everything else in the file is kept.
|
|
15
|
+
*/
|
|
16
|
+
import { dirname, relative, resolve } from "node:path";
|
|
17
|
+
import { isMap, type Document } from "yaml";
|
|
18
|
+
import type { Upstream } from "../config.js";
|
|
19
|
+
import { GATEWAY_VERSION } from "../gateway.js";
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_GATEWAY_NAME = "adtl";
|
|
22
|
+
|
|
23
|
+
export interface McpImport {
|
|
24
|
+
/** Servers moved into gateway.upstreams: name in the client config → upstream id. */
|
|
25
|
+
imported: { name: string; id: string }[];
|
|
26
|
+
/** Servers already in adtl.yaml under the same id; left as they are. */
|
|
27
|
+
kept: { name: string; id: string }[];
|
|
28
|
+
/** Servers left in the client config, with why. */
|
|
29
|
+
skipped: { name: string; reason: string }[];
|
|
30
|
+
/** The client config as it should be once the gateway replaces the imported servers. */
|
|
31
|
+
text: string;
|
|
32
|
+
changed: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Entry = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
function isRecord(v: unknown): v is Entry {
|
|
38
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function strings(v: unknown): string[] | undefined {
|
|
42
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? (v as string[]) : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stringRecord(v: unknown): Record<string, string> | undefined {
|
|
46
|
+
if (!isRecord(v)) return undefined;
|
|
47
|
+
return Object.values(v).every((x) => typeof x === "string") ? (v as Record<string, string>) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True if this client entry already runs adtl-gateway. */
|
|
51
|
+
export function isGatewayEntry(entry: Entry): boolean {
|
|
52
|
+
const words = [entry.command, ...(strings(entry.args) ?? [])].filter((w): w is string => typeof w === "string");
|
|
53
|
+
return words.some((w) => /(^|[/\\@])adtl-gateway$|@adtl\/gateway(@[^/\s]*)?$|packages[/\\]gateway[/\\]dist[/\\]cli\.js$/.test(w));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A valid upstream id (letters, digits, '-') for a client server name, unique among `taken`. */
|
|
57
|
+
export function upstreamId(name: string, taken: Set<string>): string {
|
|
58
|
+
const base = name.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "server";
|
|
59
|
+
let id = base;
|
|
60
|
+
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
|
|
61
|
+
taken.add(id);
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The upstream for one client entry, or why it can't be one. */
|
|
66
|
+
export function toUpstream(entry: Entry, mcpDir: string, configDir: string): Upstream | { skip: string } {
|
|
67
|
+
const type = typeof entry.type === "string" ? entry.type : undefined;
|
|
68
|
+
if (type === "sse") return { skip: "the gateway doesn't support SSE servers; it speaks Streamable HTTP" };
|
|
69
|
+
if (type === "http" || (type === undefined && typeof entry.url === "string")) {
|
|
70
|
+
if (typeof entry.url !== "string") return { skip: "HTTP server without a url" };
|
|
71
|
+
const headers = stringRecord(entry.headers);
|
|
72
|
+
return { url: entry.url, headers: headers ?? {} };
|
|
73
|
+
}
|
|
74
|
+
if (type !== undefined && type !== "stdio") return { skip: `unknown server type "${type}"` };
|
|
75
|
+
if (typeof entry.command !== "string" || entry.command === "") return { skip: "no command or url" };
|
|
76
|
+
// Clients start stdio servers in the project directory; the gateway resolves cwd against adtl.yaml's.
|
|
77
|
+
const cwd = relative(configDir, mcpDir);
|
|
78
|
+
const args = strings(entry.args) ?? [];
|
|
79
|
+
const env = stringRecord(entry.env) ?? {};
|
|
80
|
+
return {
|
|
81
|
+
command: entry.command,
|
|
82
|
+
args,
|
|
83
|
+
env,
|
|
84
|
+
...(cwd !== "" ? { cwd } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What runs the gateway from the client config, with adtl.yaml given relative to the client config's directory. */
|
|
89
|
+
export function gatewayEntry(configPath: string, mcpDir: string): Entry {
|
|
90
|
+
return { command: "npx", args: ["-y", `@adtl/gateway@${GATEWAY_VERSION}`, "--config", relative(mcpDir, configPath) || configPath] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Add the client's servers to `doc` (adtl.yaml) under gateway.upstreams, and
|
|
95
|
+
* return the client config rewritten to use the gateway.
|
|
96
|
+
*/
|
|
97
|
+
export function importMcpServers(
|
|
98
|
+
mcpText: string,
|
|
99
|
+
mcpPath: string,
|
|
100
|
+
doc: Document,
|
|
101
|
+
configPath: string,
|
|
102
|
+
gatewayName = DEFAULT_GATEWAY_NAME,
|
|
103
|
+
): McpImport {
|
|
104
|
+
let json: unknown;
|
|
105
|
+
try {
|
|
106
|
+
json = JSON.parse(mcpText);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new Error(`${mcpPath}: not valid JSON: ${(err as Error).message}`);
|
|
109
|
+
}
|
|
110
|
+
if (!isRecord(json) || !isRecord(json.mcpServers)) throw new Error(`${mcpPath}: no "mcpServers" object`);
|
|
111
|
+
|
|
112
|
+
const mcpDir = dirname(resolve(mcpPath));
|
|
113
|
+
const configDir = dirname(resolve(configPath));
|
|
114
|
+
const existing = doc.getIn(["gateway", "upstreams"]);
|
|
115
|
+
const taken = new Set<string>(isMap(existing) ? existing.items.map((p) => String((p.key as { value?: unknown })?.value ?? p.key)) : []);
|
|
116
|
+
const alreadyThere = new Set(taken);
|
|
117
|
+
|
|
118
|
+
const result: McpImport = { imported: [], kept: [], skipped: [], text: mcpText, changed: false };
|
|
119
|
+
const servers: Record<string, unknown> = {};
|
|
120
|
+
let gatewayKey: string | undefined;
|
|
121
|
+
|
|
122
|
+
for (const [name, entry] of Object.entries(json.mcpServers)) {
|
|
123
|
+
if (!isRecord(entry)) {
|
|
124
|
+
result.skipped.push({ name, reason: "not an object" });
|
|
125
|
+
servers[name] = entry;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (isGatewayEntry(entry)) {
|
|
129
|
+
gatewayKey ??= name;
|
|
130
|
+
servers[name] = entry;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const upstream = toUpstream(entry, mcpDir, configDir);
|
|
134
|
+
if ("skip" in upstream) {
|
|
135
|
+
result.skipped.push({ name, reason: upstream.skip });
|
|
136
|
+
servers[name] = entry;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const plain = name.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
140
|
+
if (alreadyThere.has(plain)) {
|
|
141
|
+
result.kept.push({ name, id: plain });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const id = upstreamId(name, taken);
|
|
145
|
+
// Leave out empty fields; the gateway fills them in.
|
|
146
|
+
const written = Object.fromEntries(
|
|
147
|
+
Object.entries(upstream).filter(([, v]) => !(Array.isArray(v) ? v.length === 0 : isRecord(v) && Object.keys(v).length === 0)),
|
|
148
|
+
);
|
|
149
|
+
doc.setIn(["gateway", "upstreams", id], doc.createNode(written));
|
|
150
|
+
result.imported.push({ name, id });
|
|
151
|
+
}
|
|
152
|
+
// Block style, so long paths and secrets references stay readable (a new config starts with `upstreams: {}`).
|
|
153
|
+
const upstreams = doc.getIn(["gateway", "upstreams"]);
|
|
154
|
+
if (isMap(upstreams) && result.imported.length) upstreams.flow = false;
|
|
155
|
+
|
|
156
|
+
if (result.imported.length === 0 && result.kept.length === 0) return result;
|
|
157
|
+
if (gatewayKey === undefined) {
|
|
158
|
+
let key = gatewayName;
|
|
159
|
+
for (let n = 2; key in servers; n++) key = `${gatewayName}-${n}`;
|
|
160
|
+
servers[key] = gatewayEntry(resolve(configPath), mcpDir);
|
|
161
|
+
}
|
|
162
|
+
result.text = `${JSON.stringify({ ...json, mcpServers: servers }, null, 2)}\n`;
|
|
163
|
+
result.changed = result.text !== mcpText;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
package/src/init/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { PRESETS, choosePreset, type Preset } from "./presets.js";
|
|
2
2
|
export { mergeIntoDocument, ruleFromHints, suggest, type MergeReport, type ServerScan, type ServerSuggestion, type ToolSuggestion } from "./suggest.js";
|
|
3
3
|
export { runInit, type InitOptions, type InitResult } from "./run.js";
|
|
4
|
+
export { DEFAULT_GATEWAY_NAME, gatewayEntry, importMcpServers, isGatewayEntry, toUpstream, upstreamId, type McpImport } from "./fromMcpJson.js";
|
|
5
|
+
export { NEW_CONFIG } from "./run.js";
|
package/src/init/run.ts
CHANGED
|
@@ -2,31 +2,79 @@
|
|
|
2
2
|
* `adtl-gateway init`: connect to every upstream in adtl.yaml, list its
|
|
3
3
|
* tools, and draft a labeling rule for each tool not configured yet.
|
|
4
4
|
*
|
|
5
|
+
* With `from` (e.g. `.mcp.json`), the client's MCP servers are first imported
|
|
6
|
+
* into gateway.upstreams (see fromMcpJson.ts), and adtl.yaml is created if it
|
|
7
|
+
* doesn't exist yet.
|
|
8
|
+
*
|
|
5
9
|
* Without `write`, the updated file is only returned (the CLI prints it).
|
|
6
10
|
* With `write`, the original is kept as `<file>.bak` and the file is updated
|
|
7
|
-
* in place
|
|
11
|
+
* in place; with `from`, the client config is rewritten to use the gateway,
|
|
12
|
+
* also keeping a `.bak`. Existing entries and comments are never changed.
|
|
8
13
|
*/
|
|
9
14
|
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
-
import { dirname } from "node:path";
|
|
15
|
+
import { dirname, resolve } from "node:path";
|
|
11
16
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
17
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
13
18
|
import { parseDocument } from "yaml";
|
|
14
19
|
import { expandUpstream } from "../config.js";
|
|
15
|
-
import { connectUpstream,
|
|
20
|
+
import { GatewayConfigError, connectUpstream, parseGatewayConfigText } from "../startup.js";
|
|
21
|
+
import { importMcpServers, type McpImport } from "./fromMcpJson.js";
|
|
16
22
|
import { mergeIntoDocument, suggest, type MergeReport, type ServerScan } from "./suggest.js";
|
|
17
23
|
|
|
18
24
|
export interface InitOptions {
|
|
19
25
|
path: string;
|
|
20
26
|
write?: boolean;
|
|
21
27
|
env?: NodeJS.ProcessEnv;
|
|
28
|
+
/** An MCP client config with `mcpServers` (e.g. Claude Code's .mcp.json) to import upstreams from. */
|
|
29
|
+
from?: string;
|
|
30
|
+
/** Name for the gateway's entry in that client config. Default `adtl`. */
|
|
31
|
+
gatewayName?: string;
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
/** Don't fold long values (paths, commands) across lines. */
|
|
35
|
+
const YAML_OPTIONS = { lineWidth: 0 } as const;
|
|
36
|
+
|
|
37
|
+
/** Starting point when `--from` creates adtl.yaml. */
|
|
38
|
+
export const NEW_CONFIG = `# ADTL config, drafted by \`adtl-gateway init --from\`. Review every rule marked "review".
|
|
39
|
+
#
|
|
40
|
+
# For every tool, say what its result is (integrity, confidentiality) and where
|
|
41
|
+
# calling it can send data (egress). Anything left out gets the defaults below.
|
|
42
|
+
|
|
43
|
+
defaults:
|
|
44
|
+
integrity: untrusted
|
|
45
|
+
confidentiality: private
|
|
46
|
+
|
|
47
|
+
policy:
|
|
48
|
+
default: allow
|
|
49
|
+
rules:
|
|
50
|
+
- name: no-public-write-after-untrusted
|
|
51
|
+
when:
|
|
52
|
+
session.integrity: untrusted
|
|
53
|
+
tool.egress: [public, external, unknown]
|
|
54
|
+
then: ask
|
|
55
|
+
reason: Session has read untrusted content; confirm before sending data out
|
|
56
|
+
|
|
57
|
+
- name: private-stays-private
|
|
58
|
+
when:
|
|
59
|
+
session.confidentiality: [private, secret]
|
|
60
|
+
tool.egress: public
|
|
61
|
+
then: block
|
|
62
|
+
reason: Private data must not reach a public tool
|
|
63
|
+
|
|
64
|
+
gateway:
|
|
65
|
+
upstreams: {}
|
|
66
|
+
`;
|
|
67
|
+
|
|
24
68
|
export interface InitResult {
|
|
25
69
|
/** The updated adtl.yaml. */
|
|
26
70
|
text: string;
|
|
27
71
|
reports: MergeReport[];
|
|
28
72
|
/** Upstreams that could not be scanned; left untouched in the file. */
|
|
29
73
|
failed: { id: string; error: string }[];
|
|
74
|
+
/** With `from`: what was imported, and the rewritten client config. */
|
|
75
|
+
mcp?: McpImport & { path: string };
|
|
76
|
+
/** True if adtl.yaml didn't exist and was drafted from scratch. */
|
|
77
|
+
created?: boolean;
|
|
30
78
|
}
|
|
31
79
|
|
|
32
80
|
async function listAllTools(client: Client): Promise<Tool[]> {
|
|
@@ -53,10 +101,35 @@ async function scan(id: string, connect: () => Promise<Client>): Promise<ServerS
|
|
|
53
101
|
|
|
54
102
|
export async function runInit(opts: InitOptions): Promise<InitResult> {
|
|
55
103
|
const env = opts.env ?? process.env;
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
104
|
+
const path = resolve(opts.path);
|
|
105
|
+
let original: string | undefined;
|
|
106
|
+
try {
|
|
107
|
+
original = await readFile(path, "utf8");
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT" || !opts.from) {
|
|
110
|
+
throw new GatewayConfigError(`Cannot read config file ${path}: ${(err as Error).message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const doc = parseDocument(original ?? NEW_CONFIG);
|
|
59
114
|
|
|
115
|
+
let mcp: InitResult["mcp"];
|
|
116
|
+
if (opts.from) {
|
|
117
|
+
const mcpPath = resolve(opts.from);
|
|
118
|
+
let mcpText: string;
|
|
119
|
+
try {
|
|
120
|
+
mcpText = await readFile(mcpPath, "utf8");
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw new GatewayConfigError(`Cannot read ${mcpPath}: ${(err as Error).message}`);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
mcp = { path: mcpPath, ...importMcpServers(mcpText, mcpPath, doc, path, opts.gatewayName) };
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new GatewayConfigError((err as Error).message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const loaded = parseGatewayConfigText(doc.toString(YAML_OPTIONS), path);
|
|
132
|
+
const baseDir = dirname(loaded.path);
|
|
60
133
|
const ids = Object.keys(loaded.gateway.upstreams);
|
|
61
134
|
const settled = await Promise.allSettled(
|
|
62
135
|
ids.map((id) => scan(id, () => connectUpstream(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir))),
|
|
@@ -68,12 +141,18 @@ export async function runInit(opts: InitOptions): Promise<InitResult> {
|
|
|
68
141
|
else failed.push({ id: ids[i]!, error: (s.reason as Error).message });
|
|
69
142
|
});
|
|
70
143
|
|
|
71
|
-
const doc = parseDocument(original);
|
|
72
144
|
const reports = mergeIntoDocument(doc, scans.map((s) => suggest(s)));
|
|
73
|
-
const text = doc.toString();
|
|
74
|
-
if (opts.write
|
|
75
|
-
await writeFile(
|
|
76
|
-
|
|
145
|
+
const text = doc.toString(YAML_OPTIONS);
|
|
146
|
+
if (opts.write) {
|
|
147
|
+
if (original === undefined) await writeFile(path, text, "utf8");
|
|
148
|
+
else if (text !== original) {
|
|
149
|
+
await writeFile(`${path}.bak`, original, "utf8");
|
|
150
|
+
await writeFile(path, text, "utf8");
|
|
151
|
+
}
|
|
152
|
+
if (mcp?.changed) {
|
|
153
|
+
await writeFile(`${mcp.path}.bak`, await readFile(mcp.path, "utf8"), "utf8");
|
|
154
|
+
await writeFile(mcp.path, mcp.text, "utf8");
|
|
155
|
+
}
|
|
77
156
|
}
|
|
78
|
-
return { text, reports, failed };
|
|
157
|
+
return { text, reports, failed, ...(mcp ? { mcp } : {}), ...(original === undefined ? { created: true } : {}) };
|
|
79
158
|
}
|
package/src/startup.ts
CHANGED
|
@@ -49,6 +49,12 @@ export async function loadGatewayConfig(path: string): Promise<LoadedConfig> {
|
|
|
49
49
|
} catch (err) {
|
|
50
50
|
throw new GatewayConfigError(`Cannot read config file ${abs}: ${(err as Error).message}`);
|
|
51
51
|
}
|
|
52
|
+
return parseGatewayConfigText(text, abs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Validate adtl.yaml text; `path` is where it lives (relative paths in it resolve against its directory). */
|
|
56
|
+
export function parseGatewayConfigText(text: string, path: string): LoadedConfig {
|
|
57
|
+
const abs = resolve(path);
|
|
52
58
|
let labels: AdtlConfig;
|
|
53
59
|
try {
|
|
54
60
|
labels = parseConfigText(text, abs);
|