@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
package/src/gateway.ts CHANGED
@@ -5,10 +5,18 @@
5
5
  * tools/list → every upstream's tools, renamed <upstream>__<tool>
6
6
  * tools/call → request interceptors (adtl.policy) → upstream → response
7
7
  * interceptors (adtl.label) → agent
8
+ * prompts/* → like tools: renamed <upstream>__<prompt>, results labeled
9
+ * resources/* → URIs pass through unchanged and are routed to the upstream
10
+ * that lists them (or whose template matches). Results are
11
+ * labeled. A URI more than one upstream could serve is refused,
12
+ * since ADTL could not tell which server to label it as.
8
13
  *
9
14
  * 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.
15
+ * `isError: true` explaining why, so the model can tell the user.
16
+ *
17
+ * An "ask" decision holds the call and asks a human through the approval
18
+ * broker (e.g. a phone notification). Approved calls run; denied, unanswered
19
+ * or undeliverable ones are stopped. Without an approval channel, "ask" blocks.
12
20
  *
13
21
  * In audit mode (the invoker runs every interceptor with `mode: "audit"`)
14
22
  * nothing is stopped or changed: calls the policy would stop are forwarded,
@@ -17,18 +25,33 @@
17
25
  import { randomUUID } from "node:crypto";
18
26
  import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
19
27
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
28
+ import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
20
29
  import {
21
30
  CallToolRequestSchema,
31
+ ErrorCode,
32
+ GetPromptRequestSchema,
33
+ ListPromptsRequestSchema,
34
+ ListResourcesRequestSchema,
35
+ ListResourceTemplatesRequestSchema,
22
36
  ListToolsRequestSchema,
37
+ McpError,
38
+ ReadResourceRequestSchema,
23
39
  type CallToolResult,
40
+ type GetPromptResult,
41
+ type Prompt,
42
+ type ReadResourceResult,
43
+ type Resource,
44
+ type ResourceTemplate,
45
+ type ServerCapabilities,
24
46
  type Tool,
25
47
  } from "@modelcontextprotocol/sdk/types.js";
26
48
  import type { InvocationContext } from "@adtl/interceptors";
49
+ import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
27
50
  import type { AuditEntry, AuditSink } from "./audit.js";
28
51
  import { TOOL_SEPARATOR } from "./config.js";
29
52
  import type { Blocked, ChainOutcome, InterceptorInvoker } from "./invoker.js";
30
53
 
31
- export const GATEWAY_VERSION = "0.1.1";
54
+ export const GATEWAY_VERSION = "0.3.0";
32
55
 
33
56
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
34
57
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -39,6 +62,21 @@ interface Route {
39
62
  annotations?: Tool["annotations"];
40
63
  }
41
64
 
65
+ interface PromptRoute {
66
+ upstream: string;
67
+ prompt: string;
68
+ }
69
+
70
+ interface TemplateRoute {
71
+ upstream: string;
72
+ template: UriTemplate;
73
+ }
74
+
75
+ interface Page<T> {
76
+ items: T[];
77
+ nextCursor?: string | undefined;
78
+ }
79
+
42
80
  export interface GatewayLogger {
43
81
  info(msg: string): void;
44
82
  warn(msg: string): void;
@@ -56,8 +94,18 @@ export interface AdtlGatewayOptions {
56
94
  sessionId?: string;
57
95
  audit?: AuditSink;
58
96
  logger?: GatewayLogger;
97
+ /** Asks humans about "ask" decisions and sends notices. */
98
+ approvals?: ApprovalBroker;
99
+ }
100
+
101
+ export interface CallOptions {
102
+ /** Report progress to the client while a call waits for approval. */
103
+ progress?: (progress: number, message: string) => Promise<void>;
59
104
  }
60
105
 
106
+ /** How often a waiting call reports progress, so clients that reset their timeout on progress keep waiting. */
107
+ const PROGRESS_INTERVAL_MS = 10_000;
108
+
61
109
  function textError(text: string, meta: Record<string, unknown>): CallToolResult {
62
110
  return { content: [{ type: "text", text }], isError: true, _meta: { [GATEWAY_DECISION_KEY]: meta } };
63
111
  }
@@ -66,12 +114,24 @@ function blockedMessage(b: Blocked): string {
66
114
  if (b.decision === "ask") {
67
115
  return (
68
116
  `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.`
117
+ `No approval channel is configured, so the call was not run. Tell the user what you were trying to do.`
70
118
  );
71
119
  }
72
120
  return `ADTL blocked this call: ${b.reason}. The call was not run.`;
73
121
  }
74
122
 
123
+ function approvalMessage(b: Blocked, a: ApprovalResult): string {
124
+ const why = `ADTL asked a human to approve this call because: ${b.reason}.`;
125
+ switch (a.outcome) {
126
+ case "denied":
127
+ return `${why} The request was denied, so the call was not run. Do not retry it; tell the user what you were trying to do.`;
128
+ case "timeout":
129
+ return `${why} Nobody answered in time, so the call was not run. Tell the user, who can approve it if you try again.`;
130
+ default:
131
+ return `${why} ADTL could not reach anyone to approve it, so the call was not run. Tell the user what you were trying to do.`;
132
+ }
133
+ }
134
+
75
135
  function infoOf(outcome: ChainOutcome, interceptor: string): Record<string, unknown> {
76
136
  return outcome.results.find((r) => r.interceptor === interceptor)?.info ?? {};
77
137
  }
@@ -82,7 +142,12 @@ export class AdtlGateway {
82
142
  private readonly invoker: InterceptorInvoker;
83
143
  private readonly audit: AuditSink | undefined;
84
144
  private readonly log: GatewayLogger;
145
+ private readonly approvals: ApprovalBroker | undefined;
85
146
  private routes = new Map<string, Route>();
147
+ private promptRoutes = new Map<string, PromptRoute>();
148
+ /** Resource URI → the upstreams that list it. */
149
+ private resourceRoutes = new Map<string, Set<string>>();
150
+ private templateRoutes: TemplateRoute[] = [];
86
151
 
87
152
  constructor(opts: AdtlGatewayOptions) {
88
153
  this.upstreams = opts.upstreams;
@@ -90,48 +155,244 @@ export class AdtlGateway {
90
155
  this.sessionId = opts.sessionId ?? randomUUID();
91
156
  this.audit = opts.audit;
92
157
  this.log = opts.logger ?? stderrLogger;
158
+ this.approvals = opts.approvals;
93
159
  }
94
160
 
95
161
  /** The downstream MCP server the agent connects to. */
96
162
  createServer(): Server {
163
+ // Offer resources and prompts only if some upstream does (the SDK refuses handlers for undeclared capabilities).
164
+ const offers = (c: "resources" | "prompts") => [...this.upstreams.values()].some((u) => u.getServerCapabilities()?.[c]);
165
+ const capabilities: ServerCapabilities = { tools: {} };
166
+ if (offers("resources")) capabilities.resources = {};
167
+ if (offers("prompts")) capabilities.prompts = {};
97
168
  const server = new Server(
98
169
  { name: "adtl-gateway", version: GATEWAY_VERSION },
99
170
  {
100
- capabilities: { tools: {} },
171
+ capabilities,
101
172
  instructions:
102
173
  "Tools from several MCP servers, named <server>__<tool>. ADTL checks each call against trust labels; " +
103
174
  "if a call is stopped, the result explains why.",
104
175
  },
105
176
  );
106
177
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.listTools() }));
107
- server.setRequestHandler(CallToolRequestSchema, async (req) => this.callTool(req.params.name, req.params.arguments ?? {}));
178
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
179
+ const progressToken = req.params._meta?.progressToken;
180
+ const progress =
181
+ progressToken === undefined
182
+ ? undefined
183
+ : (progress: number, message: string) =>
184
+ extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, message } });
185
+ return this.callTool(req.params.name, req.params.arguments ?? {}, progress ? { progress } : {});
186
+ });
187
+ if (capabilities.resources) {
188
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await this.listResources() }));
189
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
190
+ resourceTemplates: await this.listResourceTemplates(),
191
+ }));
192
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => this.readResource(req.params.uri));
193
+ }
194
+ if (capabilities.prompts) {
195
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await this.listPrompts() }));
196
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => this.getPrompt(req.params.name, req.params.arguments));
197
+ }
108
198
  return server;
109
199
  }
110
200
 
111
- async listTools(): Promise<Tool[]> {
112
- const routes = new Map<string, Route>();
113
- const tools: Tool[] = [];
201
+ /** Every upstream's items from one list method, following cursors. Upstreams that fail are skipped with a warning. */
202
+ private async listAll<T>(
203
+ method: string,
204
+ capability: "tools" | "resources" | "prompts",
205
+ page: (client: Client, cursor?: string) => Promise<Page<T>>,
206
+ ): Promise<{ upstream: string; item: T }[]> {
207
+ const out: { upstream: string; item: T }[] = [];
114
208
  for (const [upstream, client] of this.upstreams) {
209
+ if (capability !== "tools" && !client.getServerCapabilities()?.[capability]) continue;
115
210
  let cursor: string | undefined;
116
211
  try {
117
212
  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;
213
+ const p = await page(client, cursor);
214
+ for (const item of p.items) out.push({ upstream, item });
215
+ cursor = p.nextCursor;
125
216
  } while (cursor);
126
217
  } catch (err) {
127
- this.log.warn(`tools/list failed for ${upstream}: ${(err as Error).message}`);
218
+ this.log.warn(`${method} failed for ${upstream}: ${(err as Error).message}`);
128
219
  }
129
220
  }
221
+ return out;
222
+ }
223
+
224
+ async listTools(): Promise<Tool[]> {
225
+ const all = await this.listAll("tools/list", "tools", async (c, cursor) => {
226
+ const p = await c.listTools(cursor ? { cursor } : {});
227
+ return { items: p.tools, nextCursor: p.nextCursor };
228
+ });
229
+ const routes = new Map<string, Route>();
230
+ const tools = all.map(({ upstream, item: t }) => {
231
+ const name = `${upstream}${TOOL_SEPARATOR}${t.name}`;
232
+ routes.set(name, { upstream, tool: t.name, ...(t.annotations ? { annotations: t.annotations } : {}) });
233
+ return { ...t, name };
234
+ });
130
235
  this.routes = routes;
131
236
  return tools;
132
237
  }
133
238
 
134
- async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
239
+ async listPrompts(): Promise<Prompt[]> {
240
+ const all = await this.listAll("prompts/list", "prompts", async (c, cursor) => {
241
+ const p = await c.listPrompts(cursor ? { cursor } : {});
242
+ return { items: p.prompts, nextCursor: p.nextCursor };
243
+ });
244
+ const routes = new Map<string, PromptRoute>();
245
+ const prompts = all.map(({ upstream, item: p }) => {
246
+ const name = `${upstream}${TOOL_SEPARATOR}${p.name}`;
247
+ routes.set(name, { upstream, prompt: p.name });
248
+ return { ...p, name };
249
+ });
250
+ this.promptRoutes = routes;
251
+ return prompts;
252
+ }
253
+
254
+ async listResources(): Promise<Resource[]> {
255
+ const all = await this.listAll("resources/list", "resources", async (c, cursor) => {
256
+ const p = await c.listResources(cursor ? { cursor } : {});
257
+ return { items: p.resources, nextCursor: p.nextCursor };
258
+ });
259
+ const routes = new Map<string, Set<string>>();
260
+ for (const { upstream, item } of all) {
261
+ const ups = routes.get(item.uri) ?? new Set<string>();
262
+ ups.add(upstream);
263
+ routes.set(item.uri, ups);
264
+ }
265
+ for (const [uri, ups] of routes) {
266
+ if (ups.size > 1) this.log.warn(`resource ${uri} is offered by ${[...ups].join(", ")}; reading it will be refused`);
267
+ }
268
+ this.resourceRoutes = routes;
269
+ return all.map((a) => a.item);
270
+ }
271
+
272
+ async listResourceTemplates(): Promise<ResourceTemplate[]> {
273
+ const all = await this.listAll("resources/templates/list", "resources", async (c, cursor) => {
274
+ const p = await c.listResourceTemplates(cursor ? { cursor } : {});
275
+ return { items: p.resourceTemplates, nextCursor: p.nextCursor };
276
+ });
277
+ const routes: TemplateRoute[] = [];
278
+ for (const { upstream, item } of all) {
279
+ try {
280
+ routes.push({ upstream, template: new UriTemplate(item.uriTemplate) });
281
+ } catch (err) {
282
+ this.log.warn(`ignoring resource template ${item.uriTemplate} from ${upstream}: ${(err as Error).message}`);
283
+ }
284
+ }
285
+ this.templateRoutes = routes;
286
+ return all.map((a) => a.item);
287
+ }
288
+
289
+ /** The one upstream that serves `uri`: an exact listing wins, then template matches. */
290
+ private async resourceUpstream(uri: string): Promise<string> {
291
+ if (!this.resourceRoutes.has(uri)) await this.listResources();
292
+ let candidates = [...(this.resourceRoutes.get(uri) ?? [])];
293
+ if (candidates.length === 0) {
294
+ if (this.templateRoutes.length === 0) await this.listResourceTemplates();
295
+ candidates = [...new Set(this.templateRoutes.filter((t) => t.template.match(uri) !== null).map((t) => t.upstream))];
296
+ }
297
+ if (candidates.length === 0) throw new McpError(ErrorCode.InvalidParams, `Unknown resource: ${uri}`);
298
+ if (candidates.length > 1) {
299
+ throw new McpError(
300
+ ErrorCode.InvalidParams,
301
+ `Resource ${uri} could come from more than one server (${candidates.join(", ")}). ` +
302
+ `ADTL can't tell which server to label it as, so it was not read.`,
303
+ );
304
+ }
305
+ return candidates[0]!;
306
+ }
307
+
308
+ async readResource(uri: string): Promise<ReadResourceResult> {
309
+ const upstream = await this.resourceUpstream(uri);
310
+ const client = this.upstreams.get(upstream)!;
311
+ return this.forwardLabeled("resources/read", upstream, { resource: uri }, { uri }, (params) =>
312
+ client.readResource(params as { uri: string }),
313
+ );
314
+ }
315
+
316
+ async getPrompt(name: string, args?: Record<string, string>): Promise<GetPromptResult> {
317
+ let route = this.promptRoutes.get(name);
318
+ if (!route) {
319
+ await this.listPrompts();
320
+ route = this.promptRoutes.get(name);
321
+ }
322
+ if (!route) throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`);
323
+ const client = this.upstreams.get(route.upstream)!;
324
+ return this.forwardLabeled(
325
+ "prompts/get",
326
+ route.upstream,
327
+ { prompt: route.prompt },
328
+ { name: route.prompt, ...(args ? { arguments: args } : {}) },
329
+ (params) => client.getPrompt(params as { name: string; arguments?: Record<string, string> }),
330
+ );
331
+ }
332
+
333
+ /**
334
+ * Forward a resources/read or prompts/get request through the interceptor
335
+ * chain: request interceptors → upstream → response interceptors (adtl.label).
336
+ * These requests have no tool result to carry an error, so a stopped request
337
+ * becomes a JSON-RPC error.
338
+ */
339
+ private async forwardLabeled<R extends object>(
340
+ event: "resources/read" | "prompts/get",
341
+ upstream: string,
342
+ target: { resource: string } | { prompt: string },
343
+ params: Record<string, unknown>,
344
+ send: (params: Record<string, unknown>) => Promise<R>,
345
+ ): Promise<R> {
346
+ const started = performance.now();
347
+ const context: InvocationContext = {
348
+ sessionId: this.sessionId,
349
+ timestamp: new Date().toISOString(),
350
+ principal: { type: "anonymous" },
351
+ };
352
+ const config = { server: upstream };
353
+ const audit = this.invoker.modeOverride === "audit" ? { mode: "audit" as const } : {};
354
+ const finish = (entry: Pick<AuditEntry, "outcome"> & Partial<AuditEntry>) =>
355
+ this.audit?.write({
356
+ ts: context.timestamp!,
357
+ sessionId: this.sessionId,
358
+ event,
359
+ server: upstream,
360
+ ...target,
361
+ ...entry,
362
+ durationMs: Math.round(performance.now() - started),
363
+ });
364
+ const what = "resource" in target ? `resource ${target.resource}` : `prompt ${upstream}${TOOL_SEPARATOR}${target.prompt}`;
365
+
366
+ const req = await this.invoker.runRequest({ event, payload: { method: event, params }, config, context });
367
+ if (req.blocked) {
368
+ finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, warnings: req.warnings });
369
+ this.log.info(`${req.blocked.decision} ${what}: ${req.blocked.reason}`);
370
+ throw new McpError(ErrorCode.InvalidRequest, `ADTL blocked this request: ${req.blocked.reason}`);
371
+ }
372
+ const forwarded = (req.payload as { params: Record<string, unknown> }).params;
373
+
374
+ let result: R;
375
+ try {
376
+ result = await send(forwarded);
377
+ } catch (err) {
378
+ finish({ outcome: "upstream_error", reason: (err as Error).message, ...audit, warnings: req.warnings });
379
+ throw err;
380
+ }
381
+
382
+ const res = await this.invoker.runResponse({ event, payload: { method: event, params: forwarded, result }, config, context });
383
+ const warnings = [...req.warnings, ...res.warnings];
384
+ if (res.blocked) {
385
+ // Fail closed: unlabeled content must not reach the agent.
386
+ finish({ outcome: "blocked", decision: res.blocked.decision, reason: res.blocked.reason, warnings });
387
+ this.log.warn(`${what} withheld: ${res.blocked.reason}`);
388
+ throw new McpError(ErrorCode.InternalError, `ADTL could not label this content, so it was withheld: ${res.blocked.reason}`);
389
+ }
390
+ const label = infoOf(res, "adtl.label");
391
+ finish({ outcome: "forwarded", ...audit, label: label.label, sessionLabel: label.sessionLabel, warnings });
392
+ return (res.payload as { result: R }).result;
393
+ }
394
+
395
+ async callTool(name: string, args: Record<string, unknown>, opts: CallOptions = {}): Promise<CallToolResult> {
135
396
  const started = performance.now();
136
397
  let route = this.routes.get(name);
137
398
  if (!route) {
@@ -158,13 +419,39 @@ export class AdtlGateway {
158
419
  const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
159
420
  const req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
160
421
  const policy = infoOf(req, "adtl.policy");
422
+ let approval: ApprovalResult | undefined;
161
423
  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 });
424
+ const b = req.blocked;
425
+ const stopped = { decision: b.decision, reason: b.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel };
426
+ const matched = Array.isArray(policy.matched) ? (policy.matched as string[]) : [];
427
+ if (b.decision === "ask" && this.approvals?.canApprove && typeof policy.callHash === "string") {
428
+ this.log.info(`ask ${name}: waiting for approval (${b.reason})`);
429
+ approval = await this.waitForApproval(opts, () =>
430
+ this.approvals!.request({
431
+ sessionId: this.sessionId,
432
+ server: route.upstream,
433
+ tool: route.tool,
434
+ callHash: policy.callHash as string,
435
+ reason: b.reason,
436
+ matched,
437
+ ...(policy.sessionLabel !== undefined ? { sessionLabel: policy.sessionLabel } : {}),
438
+ }),
439
+ );
440
+ this.log.info(`ask ${name}: ${approval.outcome}`);
441
+ if (approval.outcome !== "approved") {
442
+ finish({ outcome: "blocked", ...stopped, approval, warnings: req.warnings });
443
+ return textError(approvalMessage(b, approval), { ...b, callHash: policy.callHash, approval });
444
+ }
445
+ } else {
446
+ finish({ outcome: "blocked", ...stopped, warnings: req.warnings });
447
+ this.log.info(`${b.decision} ${name}: ${b.reason}`);
448
+ this.approvals?.blocked({ sessionId: this.sessionId, server: route.upstream, tool: route.tool, reason: b.reason, matched });
449
+ return textError(blockedMessage(b), { ...b, callHash: policy.callHash });
450
+ }
165
451
  }
166
452
  const forwarded = (req.payload as typeof requestPayload).params;
167
453
  const decision = policy["adtl/decision"] as string | undefined;
454
+ const approved = approval ? { approval } : {};
168
455
  const audit = this.invoker.modeOverride === "audit";
169
456
  // Only set in audit mode, for calls the policy would have stopped.
170
457
  const wouldStop =
@@ -179,7 +466,7 @@ export class AdtlGateway {
179
466
  result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments })) as CallToolResult;
180
467
  } catch (err) {
181
468
  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 });
469
+ finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), ...approved, reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
183
470
  return textError(`The ${route.upstream} server returned an error: ${reason}`, { reason: "upstream_error" });
184
471
  }
185
472
 
@@ -202,6 +489,7 @@ export class AdtlGateway {
202
489
  // Missing only if adtl.policy failed and was allowed to (audit mode); see warnings.
203
490
  ...(decision ? { decision } : {}),
204
491
  ...(wouldStop ?? {}),
492
+ ...approved,
205
493
  ...(audit ? { mode: "audit" as const } : {}),
206
494
  callHash: policy.callHash,
207
495
  label: label.label,
@@ -210,4 +498,18 @@ export class AdtlGateway {
210
498
  });
211
499
  return (res.payload as { result: CallToolResult }).result;
212
500
  }
501
+
502
+ /** Run an approval request, reporting progress to the client while it waits. */
503
+ private async waitForApproval(opts: CallOptions, ask: () => Promise<ApprovalResult>): Promise<ApprovalResult> {
504
+ if (!opts.progress) return ask();
505
+ let n = 0;
506
+ const report = () => void opts.progress!(++n, "Waiting for a human to approve this call").catch(() => undefined);
507
+ report();
508
+ const timer = setInterval(report, PROGRESS_INTERVAL_MS);
509
+ try {
510
+ return await ask();
511
+ } finally {
512
+ clearInterval(timer);
513
+ }
514
+ }
213
515
  }
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export * from "./config.js";
2
2
  export * from "./invoker.js";
3
3
  export * from "./audit.js";
4
+ export * from "./approvals.js";
5
+ export * from "./ntfy.js";
6
+ export * from "./email.js";
4
7
  export * from "./gateway.js";
5
8
  export * from "./startup.js";
9
+ export * from "./init/index.js";
@@ -0,0 +1,3 @@
1
+ export { PRESETS, choosePreset, type Preset } from "./presets.js";
2
+ export { mergeIntoDocument, ruleFromHints, suggest, type MergeReport, type ServerScan, type ServerSuggestion, type ToolSuggestion } from "./suggest.js";
3
+ export { runInit, type InitOptions, type InitResult } from "./run.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Presets for well-known MCP servers, used by `adtl-gateway init`.
3
+ *
4
+ * A preset says what a server's reads and writes mean, and the server's own
5
+ * `readOnlyHint` decides which tools are reads. That keeps a preset correct as
6
+ * the server adds tools (the GitHub server has about 90 and adds more).
7
+ *
8
+ * Presets are only a starting point: `init` writes one explicit line per tool
9
+ * into adtl.yaml, and the gateway owner reviews and edits them.
10
+ */
11
+ import type { ToolRule } from "@adtl/labels";
12
+
13
+ export interface Preset {
14
+ id: string;
15
+ /** `serverInfo.name` values the server reports at initialize. */
16
+ serverNames: string[];
17
+ /** Server defaults: resources, prompts, and tools added after `init` ran. */
18
+ defaults: ToolRule;
19
+ /** Tools with `readOnlyHint: true`. */
20
+ read: ToolRule;
21
+ /** All other tools. Fields left out fall back to `defaults`. */
22
+ write: ToolRule;
23
+ /** Exact per-tool overrides. */
24
+ tools?: Record<string, ToolRule>;
25
+ /** One line written above the server in adtl.yaml. */
26
+ note: string;
27
+ }
28
+
29
+ export const PRESETS: Preset[] = [
30
+ {
31
+ id: "github",
32
+ serverNames: ["github-mcp-server"],
33
+ // Issues, PRs, comments and code can be written by anyone who can open an issue.
34
+ defaults: { integrity: "untrusted", confidentiality: "private", egress: "public" },
35
+ read: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
36
+ write: { egress: "public" },
37
+ note: "preset github: content is untrusted (anyone can write issues), private repos make it private; writes are public",
38
+ },
39
+ {
40
+ id: "filesystem",
41
+ serverNames: ["secure-filesystem-server"],
42
+ defaults: { integrity: "trusted", confidentiality: "private", egress: "internal" },
43
+ read: { integrity: "trusted", confidentiality: "private", egress: "none" },
44
+ write: { egress: "internal" },
45
+ note: "preset filesystem: files are trusted; set integrity: untrusted if these folders hold downloads or cloned repos",
46
+ },
47
+ {
48
+ id: "fetch",
49
+ serverNames: ["mcp-fetch"],
50
+ // The URL itself can carry data out, so even a read is external egress.
51
+ defaults: { integrity: "untrusted", confidentiality: "public", egress: "external" },
52
+ read: { integrity: "untrusted", confidentiality: "public", egress: "external" },
53
+ write: { integrity: "untrusted", confidentiality: "public", egress: "external" },
54
+ note: "preset fetch: web pages are untrusted; a fetched URL can carry data out, so fetch is external egress",
55
+ },
56
+ ];
57
+
58
+ /** The preset for an upstream: by the name the server reports, then by the upstream id. */
59
+ export function choosePreset(upstreamId: string, serverName: string | undefined, presets = PRESETS): Preset | undefined {
60
+ return (
61
+ (serverName !== undefined ? presets.find((p) => p.serverNames.includes(serverName)) : undefined) ??
62
+ presets.find((p) => p.id === upstreamId)
63
+ );
64
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `adtl-gateway init`: connect to every upstream in adtl.yaml, list its
3
+ * tools, and draft a labeling rule for each tool not configured yet.
4
+ *
5
+ * Without `write`, the updated file is only returned (the CLI prints it).
6
+ * With `write`, the original is kept as `<file>.bak` and the file is updated
7
+ * in place. Existing entries and comments are never changed.
8
+ */
9
+ import { readFile, writeFile } from "node:fs/promises";
10
+ import { dirname } from "node:path";
11
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
13
+ import { parseDocument } from "yaml";
14
+ import { expandUpstream } from "../config.js";
15
+ import { connectUpstream, loadGatewayConfig } from "../startup.js";
16
+ import { mergeIntoDocument, suggest, type MergeReport, type ServerScan } from "./suggest.js";
17
+
18
+ export interface InitOptions {
19
+ path: string;
20
+ write?: boolean;
21
+ env?: NodeJS.ProcessEnv;
22
+ }
23
+
24
+ export interface InitResult {
25
+ /** The updated adtl.yaml. */
26
+ text: string;
27
+ reports: MergeReport[];
28
+ /** Upstreams that could not be scanned; left untouched in the file. */
29
+ failed: { id: string; error: string }[];
30
+ }
31
+
32
+ async function listAllTools(client: Client): Promise<Tool[]> {
33
+ const tools: Tool[] = [];
34
+ let cursor: string | undefined;
35
+ do {
36
+ const page = await client.listTools(cursor ? { cursor } : {});
37
+ tools.push(...page.tools);
38
+ cursor = page.nextCursor;
39
+ } while (cursor);
40
+ return tools;
41
+ }
42
+
43
+ async function scan(id: string, connect: () => Promise<Client>): Promise<ServerScan> {
44
+ const client = await connect();
45
+ try {
46
+ const name = client.getServerVersion()?.name;
47
+ const tools = await listAllTools(client);
48
+ return { id, ...(name ? { serverName: name } : {}), tools: tools.map((t) => ({ name: t.name, annotations: t.annotations })) };
49
+ } finally {
50
+ await client.close().catch(() => undefined);
51
+ }
52
+ }
53
+
54
+ export async function runInit(opts: InitOptions): Promise<InitResult> {
55
+ const env = opts.env ?? process.env;
56
+ const loaded = await loadGatewayConfig(opts.path);
57
+ const baseDir = dirname(loaded.path);
58
+ const original = await readFile(loaded.path, "utf8");
59
+
60
+ const ids = Object.keys(loaded.gateway.upstreams);
61
+ const settled = await Promise.allSettled(
62
+ ids.map((id) => scan(id, () => connectUpstream(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir))),
63
+ );
64
+ const scans: ServerScan[] = [];
65
+ const failed: InitResult["failed"] = [];
66
+ settled.forEach((s, i) => {
67
+ if (s.status === "fulfilled") scans.push(s.value);
68
+ else failed.push({ id: ids[i]!, error: (s.reason as Error).message });
69
+ });
70
+
71
+ const doc = parseDocument(original);
72
+ const reports = mergeIntoDocument(doc, scans.map((s) => suggest(s)));
73
+ const text = doc.toString();
74
+ if (opts.write && text !== original) {
75
+ await writeFile(`${loaded.path}.bak`, original, "utf8");
76
+ await writeFile(loaded.path, text, "utf8");
77
+ }
78
+ return { text, reports, failed };
79
+ }