@adtl/gateway 0.1.1 → 0.3.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.
Files changed (69) hide show
  1. package/README.md +57 -5
  2. package/dist/approvals.d.ts +97 -0
  3. package/dist/approvals.d.ts.map +1 -0
  4. package/dist/approvals.js +151 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/audit.d.ts +12 -2
  7. package/dist/audit.d.ts.map +1 -1
  8. package/dist/audit.js +1 -1
  9. package/dist/audit.js.map +1 -1
  10. package/dist/cli.js +46 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/config.d.ts +346 -4
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +63 -2
  15. package/dist/config.js.map +1 -1
  16. package/dist/email.d.ts +35 -0
  17. package/dist/email.d.ts.map +1 -0
  18. package/dist/email.js +63 -0
  19. package/dist/email.js.map +1 -0
  20. package/dist/gateway.d.ts +33 -3
  21. package/dist/gateway.d.ts.map +1 -1
  22. package/dist/gateway.js +263 -23
  23. package/dist/gateway.js.map +1 -1
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/init/index.d.ts +4 -0
  29. package/dist/init/index.d.ts.map +1 -0
  30. package/dist/init/index.js +4 -0
  31. package/dist/init/index.js.map +1 -0
  32. package/dist/init/presets.d.ts +30 -0
  33. package/dist/init/presets.d.ts.map +1 -0
  34. package/dist/init/presets.js +34 -0
  35. package/dist/init/presets.js.map +1 -0
  36. package/dist/init/run.d.ts +18 -0
  37. package/dist/init/run.d.ts.map +1 -0
  38. package/dist/init/run.js +60 -0
  39. package/dist/init/run.js.map +1 -0
  40. package/dist/init/suggest.d.ts +50 -0
  41. package/dist/init/suggest.d.ts.map +1 -0
  42. package/dist/init/suggest.js +82 -0
  43. package/dist/init/suggest.js.map +1 -0
  44. package/dist/invoker.d.ts.map +1 -1
  45. package/dist/invoker.js +4 -2
  46. package/dist/invoker.js.map +1 -1
  47. package/dist/ntfy.d.ts +44 -0
  48. package/dist/ntfy.d.ts.map +1 -0
  49. package/dist/ntfy.js +133 -0
  50. package/dist/ntfy.js.map +1 -0
  51. package/dist/startup.d.ts +6 -1
  52. package/dist/startup.d.ts.map +1 -1
  53. package/dist/startup.js +37 -5
  54. package/dist/startup.js.map +1 -1
  55. package/package.json +4 -3
  56. package/src/approvals.ts +219 -0
  57. package/src/audit.ts +8 -3
  58. package/src/cli.ts +41 -1
  59. package/src/config.ts +65 -2
  60. package/src/email.ts +79 -0
  61. package/src/gateway.ts +324 -22
  62. package/src/index.ts +4 -0
  63. package/src/init/index.ts +3 -0
  64. package/src/init/presets.ts +64 -0
  65. package/src/init/run.ts +79 -0
  66. package/src/init/suggest.ts +127 -0
  67. package/src/invoker.ts +3 -2
  68. package/src/ntfy.ts +154 -0
  69. package/src/startup.ts +35 -4
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Draft ADTL rules for an upstream's tools, and merge them into adtl.yaml.
3
+ *
4
+ * Without a preset, rules come from the MCP tool annotations. Those are the
5
+ * server's own claims, so they only decide egress; integrity is always
6
+ * untrusted and confidentiality private until the owner says otherwise.
7
+ * Missing hints take the MCP spec defaults (readOnlyHint false, openWorldHint true).
8
+ *
9
+ * Merging never changes an entry that is already in the file: the owner's
10
+ * choices win, and `init` can be re-run to pick up new tools.
11
+ */
12
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
13
+ import type { ToolRule } from "@adtl/labels";
14
+ import { isMap, isScalar, type Document, type YAMLMap } from "yaml";
15
+ import { choosePreset, PRESETS, type Preset } from "./presets.js";
16
+
17
+ export interface ServerScan {
18
+ /** Upstream id in gateway.upstreams. */
19
+ id: string;
20
+ /** `serverInfo.name` the server reported. */
21
+ serverName?: string;
22
+ tools: Pick<Tool, "name" | "annotations">[];
23
+ }
24
+
25
+ export interface ToolSuggestion {
26
+ name: string;
27
+ rule: ToolRule;
28
+ /** Short reason, written as a comment next to the rule. */
29
+ why: string;
30
+ }
31
+
32
+ export interface ServerSuggestion {
33
+ id: string;
34
+ preset?: Preset;
35
+ defaults: ToolRule;
36
+ tools: ToolSuggestion[];
37
+ }
38
+
39
+ const UNKNOWN_DEFAULTS: ToolRule = { integrity: "untrusted", confidentiality: "private" };
40
+
41
+ /** A rule from the tool's MCP annotations alone. */
42
+ export function ruleFromHints(annotations: Tool["annotations"]): Omit<ToolSuggestion, "name"> {
43
+ const readOnly = annotations?.readOnlyHint === true;
44
+ const openWorld = annotations?.openWorldHint !== false;
45
+ const egress = readOnly ? (openWorld ? "external" : "none") : openWorld ? "public" : "internal";
46
+ const why = `hints: ${readOnly ? "read-only" : "writes"}, ${openWorld ? "open world" : "closed world"} (review)`;
47
+ return { rule: { integrity: "untrusted", confidentiality: "private", egress }, why };
48
+ }
49
+
50
+ export function suggest(scan: ServerScan, presets = PRESETS): ServerSuggestion {
51
+ const preset = choosePreset(scan.id, scan.serverName, presets);
52
+ const tools = scan.tools.map(({ name, annotations }): ToolSuggestion => {
53
+ if (!preset) return { name, ...ruleFromHints(annotations) };
54
+ const override = preset.tools?.[name];
55
+ if (override) return { name, rule: override, why: `preset ${preset.id}` };
56
+ const read = annotations?.readOnlyHint === true;
57
+ return { name, rule: read ? preset.read : preset.write, why: `preset ${preset.id}: ${read ? "read" : "write"}` };
58
+ });
59
+ return { id: scan.id, ...(preset ? { preset } : {}), defaults: preset?.defaults ?? UNKNOWN_DEFAULTS, tools };
60
+ }
61
+
62
+ export interface MergeReport {
63
+ id: string;
64
+ preset?: string;
65
+ added: string[];
66
+ /** Tools already configured; left as they were. */
67
+ kept: string[];
68
+ /** Configured tools the server no longer offers. Left in place, reported only. */
69
+ stale: string[];
70
+ addedDefaults: boolean;
71
+ }
72
+
73
+ function mapAt(doc: Document, parent: YAMLMap, key: string, commentBefore?: string): YAMLMap {
74
+ const existing = parent.get(key, true);
75
+ if (isMap(existing)) return existing;
76
+ const map = doc.createNode({}) as YAMLMap;
77
+ const k = doc.createNode(key);
78
+ if (commentBefore && isScalar(k)) k.commentBefore = ` ${commentBefore}`;
79
+ parent.add(doc.createPair(k, map));
80
+ return map;
81
+ }
82
+
83
+ /** Add the suggestions to the document's `servers:` section, keeping every existing entry. */
84
+ export function mergeIntoDocument(doc: Document, suggestions: ServerSuggestion[]): MergeReport[] {
85
+ let servers = doc.get("servers", true);
86
+ if (!isMap(servers)) {
87
+ servers = doc.createNode({}) as YAMLMap;
88
+ // Put servers: first, ahead of policy: and gateway:.
89
+ const root = doc.contents;
90
+ if (isMap(root)) root.items.unshift(doc.createPair("servers", servers));
91
+ else doc.contents = doc.createNode({ servers }) as never;
92
+ }
93
+ const serversMap = servers as YAMLMap;
94
+
95
+ return suggestions.map((s) => {
96
+ const report: MergeReport = { id: s.id, added: [], kept: [], stale: [], addedDefaults: false };
97
+ if (s.preset) report.preset = s.preset.id;
98
+ const isNew = !serversMap.has(s.id);
99
+ const server = mapAt(doc, serversMap, s.id, isNew ? (s.preset?.note ?? "from MCP tool hints: review every line") : undefined);
100
+
101
+ if (!server.has("defaults")) {
102
+ const d = doc.createNode(s.defaults, { flow: true });
103
+ d.comment = " resources, prompts, and tools not listed below";
104
+ server.items.unshift(doc.createPair("defaults", d));
105
+ report.addedDefaults = true;
106
+ }
107
+
108
+ const tools = mapAt(doc, server, "tools");
109
+ tools.flow = false;
110
+ const offered = new Set(s.tools.map((t) => t.name));
111
+ for (const t of s.tools) {
112
+ if (tools.has(t.name)) {
113
+ report.kept.push(t.name);
114
+ continue;
115
+ }
116
+ const v = doc.createNode(t.rule, { flow: true });
117
+ v.comment = ` ${t.why}`;
118
+ tools.set(t.name, v);
119
+ report.added.push(t.name);
120
+ }
121
+ for (const item of tools.items) {
122
+ const name = isScalar(item.key) ? String(item.key.value) : String(item.key);
123
+ if (!offered.has(name)) report.stale.push(name);
124
+ }
125
+ return report;
126
+ });
127
+ }
package/src/invoker.ts CHANGED
@@ -174,10 +174,11 @@ export class InterceptorInvoker {
174
174
  const r = s.value as ValidationResult;
175
175
  out.results.push(r);
176
176
  if (r.valid || r.severity !== "error" || this.isAudit(i)) return;
177
- if (!out.blocked) {
177
+ // Keep the strictest rejection, so an "ask" never hides another validator's "block".
178
+ if (!out.blocked || out.blocked.decision === "ask") {
178
179
  const decision = (r.info?.[DECISION_INFO_KEY] as Decision | undefined) ?? "block";
179
180
  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
+ if (!out.blocked || decision === "block") out.blocked = { interceptor: i.definition.name, decision, reason };
181
182
  }
182
183
  ok = false;
183
184
  });
package/src/ntfy.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ntfy (https://ntfy.sh) push notifications with Allow / Deny buttons.
3
+ *
4
+ * The buttons make the phone POST a signed answer token to a reply topic on
5
+ * the same ntfy server, and the gateway subscribes to that topic. So answers
6
+ * reach the gateway without it ever accepting inbound connections.
7
+ *
8
+ * Messages name the tool, server and reason, never arguments or results.
9
+ * Anyone who can read the topic can press the buttons, so use a long random
10
+ * topic name or an access-controlled topic on your own server.
11
+ */
12
+ import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
13
+
14
+ export interface NtfyOptions {
15
+ /** Server base URL, e.g. https://ntfy.sh */
16
+ server: string;
17
+ topic: string;
18
+ /** Topic answers are posted to. Default: `<topic>-reply`. */
19
+ replyTopic?: string;
20
+ /** Access token for protected topics. */
21
+ token?: string;
22
+ fetch?: typeof fetch;
23
+ /** Delay before re-subscribing after the stream drops, and the base delay between publish retries. */
24
+ reconnectMs?: number;
25
+ /** Publish attempts before giving up (network errors, 429 and 5xx are retried). */
26
+ publishAttempts?: number;
27
+ }
28
+
29
+ function describe(request: ApprovalRequest): string {
30
+ const secs = Math.max(0, Math.round((request.expiresAt.getTime() - request.createdAt.getTime()) / 1000));
31
+ return `${request.reason}\n\nRule: ${request.matched.join(", ") || "default"}\nExpires in ${secs}s; no answer means deny.`;
32
+ }
33
+
34
+ export class NtfyNotifier implements Notifier {
35
+ readonly name = "ntfy";
36
+ private readonly server: string;
37
+ private readonly topic: string;
38
+ private readonly replyTopic: string;
39
+ private readonly token: string | undefined;
40
+ private readonly fetch: typeof fetch;
41
+ private readonly reconnectMs: number;
42
+ private readonly publishAttempts: number;
43
+
44
+ constructor(opts: NtfyOptions) {
45
+ this.server = opts.server.replace(/\/+$/, "");
46
+ this.topic = opts.topic;
47
+ this.replyTopic = opts.replyTopic ?? `${opts.topic}-reply`;
48
+ this.token = opts.token;
49
+ this.fetch = opts.fetch ?? globalThis.fetch;
50
+ this.reconnectMs = opts.reconnectMs ?? 1000;
51
+ this.publishAttempts = opts.publishAttempts ?? 3;
52
+ }
53
+
54
+ private get auth(): Record<string, string> {
55
+ return this.token ? { Authorization: `Bearer ${this.token}` } : {};
56
+ }
57
+
58
+ async notify(event: ApprovalEvent): Promise<void> {
59
+ if (event.kind === "blocked") return; // push is for decisions; email covers blocked notices
60
+ const { request } = event;
61
+ const subject = `${request.server}.${request.tool}`;
62
+ let message: Record<string, unknown>;
63
+ if (event.kind === "pending") {
64
+ const reply = `${this.server}/${encodeURIComponent(this.replyTopic)}`;
65
+ const button = (label: string, body: string) => ({
66
+ action: "http",
67
+ label,
68
+ url: reply,
69
+ method: "POST",
70
+ body,
71
+ clear: true,
72
+ ...(this.token ? { headers: this.auth } : {}),
73
+ });
74
+ message = {
75
+ title: `ADTL: allow ${subject}?`,
76
+ message: describe(request),
77
+ priority: 4,
78
+ tags: ["warning"],
79
+ actions: [button("Allow", event.tokens.allow), button("Deny", event.tokens.deny)],
80
+ };
81
+ } else {
82
+ message = {
83
+ title: `ADTL: ${subject} ${event.result.outcome}`,
84
+ message: `Approval ${event.result.outcome} after ${Math.round(event.result.waitedMs / 1000)}s.`,
85
+ priority: 2,
86
+ tags: [event.result.outcome === "approved" ? "white_check_mark" : "x"],
87
+ };
88
+ }
89
+ await this.publish(JSON.stringify({ topic: this.topic, ...message }));
90
+ }
91
+
92
+ /** POST with retries: flaky networks should not turn an approval request into a denial. */
93
+ private async publish(body: string): Promise<void> {
94
+ for (let attempt = 1; ; attempt++) {
95
+ let res: Response | undefined;
96
+ try {
97
+ res = await this.fetch(this.server, { method: "POST", headers: { "Content-Type": "application/json", ...this.auth }, body });
98
+ } catch (err) {
99
+ if (attempt >= this.publishAttempts) throw err;
100
+ }
101
+ if (res?.ok) return;
102
+ if (res && ((res.status !== 429 && res.status < 500) || attempt >= this.publishAttempts)) {
103
+ throw new Error(`ntfy publish failed: HTTP ${res.status}`);
104
+ }
105
+ await new Promise((r) => setTimeout(r, this.reconnectMs * attempt));
106
+ }
107
+ }
108
+
109
+ async listen(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void> {
110
+ // `since` replays anything posted after the request was created, so a fast
111
+ // answer is not lost while (re)connecting.
112
+ const since = Math.floor(request.createdAt.getTime() / 1000) - 1;
113
+ const url = `${this.server}/${encodeURIComponent(this.replyTopic)}/json?since=${since}`;
114
+ while (!signal.aborted) {
115
+ try {
116
+ const res = await this.fetch(url, { headers: this.auth, signal });
117
+ if (!res.ok || !res.body) throw new Error(`ntfy subscribe failed: HTTP ${res.status}`);
118
+ await this.read(res.body, answer, signal);
119
+ } catch (err) {
120
+ if (signal.aborted) return;
121
+ if ((err as Error).message.includes("HTTP 401") || (err as Error).message.includes("HTTP 403")) throw err;
122
+ }
123
+ if (!signal.aborted) await sleep(this.reconnectMs, signal);
124
+ }
125
+ }
126
+
127
+ private async read(body: ReadableStream<Uint8Array>, answer: AnswerFn, signal: AbortSignal): Promise<void> {
128
+ const decoder = new TextDecoder();
129
+ let buf = "";
130
+ for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
131
+ if (signal.aborted) return;
132
+ buf += decoder.decode(chunk, { stream: true });
133
+ let nl: number;
134
+ while ((nl = buf.indexOf("\n")) >= 0) {
135
+ const line = buf.slice(0, nl).trim();
136
+ buf = buf.slice(nl + 1);
137
+ if (!line) continue;
138
+ try {
139
+ const msg = JSON.parse(line) as { event?: string; message?: string };
140
+ if (msg.event === "message" && typeof msg.message === "string") answer(msg.message);
141
+ } catch {
142
+ // Not JSON: ignore.
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
150
+ return new Promise((resolve) => {
151
+ const t = setTimeout(resolve, ms);
152
+ signal.addEventListener("abort", () => (clearTimeout(t), resolve()), { once: true });
153
+ });
154
+ }
package/src/startup.ts CHANGED
@@ -12,17 +12,21 @@ import { INTERCEPTORS_EXTENSION, LABEL_INTERCEPTOR, POLICY_INTERCEPTOR, parseCon
12
12
  import type { AdtlConfig } from "@adtl/labels";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { ZodError } from "zod";
15
+ import { ApprovalBroker, type Notifier } from "./approvals.js";
15
16
  import { JsonlAuditLog } from "./audit.js";
16
17
  import {
17
18
  GatewayConfigSchema,
19
+ expandApprovals,
18
20
  expandUpstream,
19
21
  isHttpUpstream,
20
22
  type GatewayConfig,
21
23
  type StdioUpstream,
22
24
  type Upstream,
23
25
  } from "./config.js";
26
+ import { EmailNotifier } from "./email.js";
24
27
  import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
25
28
  import { InterceptorInvoker } from "./invoker.js";
29
+ import { NtfyNotifier } from "./ntfy.js";
26
30
 
27
31
  export class GatewayConfigError extends Error {
28
32
  constructor(message: string) {
@@ -84,7 +88,7 @@ function stdioTransport(u: StdioUpstream, baseDir: string): StdioClientTransport
84
88
  });
85
89
  }
86
90
 
87
- async function connect(id: string, u: Upstream, baseDir: string): Promise<Client> {
91
+ export async function connectUpstream(id: string, u: Upstream, baseDir: string): Promise<Client> {
88
92
  const client = new Client({ name: `adtl-gateway→${id}`, version: GATEWAY_VERSION });
89
93
  const transport = isHttpUpstream(u)
90
94
  ? new StreamableHTTPClientTransport(new URL(u.url), { requestInit: { headers: u.headers } })
@@ -93,6 +97,26 @@ async function connect(id: string, u: Upstream, baseDir: string): Promise<Client
93
97
  return client;
94
98
  }
95
99
 
100
+ /** Build the approval broker from `gateway.approvals`, or undefined if not configured. */
101
+ export function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log: GatewayLogger): ApprovalBroker | undefined {
102
+ if (!cfg.approvals) return undefined;
103
+ const a = expandApprovals(cfg.approvals, env);
104
+ const notifiers: Notifier[] = [];
105
+ if (a.ntfy) {
106
+ if (/^https:\/\/ntfy\.sh\/?$/.test(a.ntfy.server) && a.ntfy.topic.length < 20 && !a.ntfy.token) {
107
+ log.warn("gateway.approvals.ntfy.topic is short and on the public ntfy.sh server: anyone who guesses it can approve calls. Use a long random topic.");
108
+ }
109
+ notifiers.push(new NtfyNotifier(a.ntfy));
110
+ }
111
+ if (a.email) {
112
+ notifiers.push(new EmailNotifier({ smtp: a.email.smtp, from: a.email.from, to: [a.email.to].flat(), on: a.email.on }));
113
+ }
114
+ if (notifiers.length === 0) return undefined;
115
+ if (!notifiers.some((n) => n.listen)) log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
116
+ log.info(`approvals: ${notifiers.map((n) => n.name).join(", ")} (timeout ${a.timeoutSeconds}s)`);
117
+ return new ApprovalBroker({ notifiers, timeoutMs: a.timeoutSeconds * 1000, log });
118
+ }
119
+
96
120
  export interface StartedGateway {
97
121
  gateway: AdtlGateway;
98
122
  close(): Promise<void>;
@@ -113,7 +137,7 @@ export async function startGateway(
113
137
  const icfg: StdioUpstream = loaded.gateway.interceptors
114
138
  ? expandUpstream("interceptors", loaded.gateway.interceptors, env)
115
139
  : { command: process.execPath, args: [bundledInterceptorsCli(), "--config", loaded.path], env: {} };
116
- const interceptors = await connect("interceptors", icfg, baseDir);
140
+ const interceptors = await connectUpstream("interceptors", icfg, baseDir);
117
141
  clients.push(interceptors);
118
142
  if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
119
143
  throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
@@ -134,7 +158,7 @@ export async function startGateway(
134
158
  const upstreams = new Map<string, Client>();
135
159
  const ids = Object.keys(loaded.gateway.upstreams);
136
160
  const settled = await Promise.allSettled(
137
- ids.map((id) => connect(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir)),
161
+ ids.map((id) => connectUpstream(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir)),
138
162
  );
139
163
  settled.forEach((s, i) => {
140
164
  const id = ids[i]!;
@@ -152,7 +176,14 @@ export async function startGateway(
152
176
  log.info(`upstreams: ${[...upstreams.keys()].join(", ")}`);
153
177
 
154
178
  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 });
179
+ const approvals = createApprovals(loaded.gateway, env, log);
180
+ const gateway = new AdtlGateway({
181
+ upstreams,
182
+ invoker,
183
+ ...(auditLog ? { audit: auditLog } : {}),
184
+ ...(approvals ? { approvals } : {}),
185
+ logger: log,
186
+ });
156
187
  return { gateway, close: closeAll };
157
188
  } catch (err) {
158
189
  await closeAll();