@adtl/gateway 0.2.0 → 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 (45) hide show
  1. package/README.md +21 -2
  2. package/dist/audit.d.ts +5 -2
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +1 -1
  5. package/dist/audit.js.map +1 -1
  6. package/dist/cli.js +46 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/gateway.d.ts +22 -2
  9. package/dist/gateway.d.ts.map +1 -1
  10. package/dist/gateway.js +189 -14
  11. package/dist/gateway.js.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/init/index.d.ts +4 -0
  17. package/dist/init/index.d.ts.map +1 -0
  18. package/dist/init/index.js +4 -0
  19. package/dist/init/index.js.map +1 -0
  20. package/dist/init/presets.d.ts +30 -0
  21. package/dist/init/presets.d.ts.map +1 -0
  22. package/dist/init/presets.js +34 -0
  23. package/dist/init/presets.js.map +1 -0
  24. package/dist/init/run.d.ts +18 -0
  25. package/dist/init/run.d.ts.map +1 -0
  26. package/dist/init/run.js +60 -0
  27. package/dist/init/run.js.map +1 -0
  28. package/dist/init/suggest.d.ts +50 -0
  29. package/dist/init/suggest.d.ts.map +1 -0
  30. package/dist/init/suggest.js +82 -0
  31. package/dist/init/suggest.js.map +1 -0
  32. package/dist/startup.d.ts +3 -1
  33. package/dist/startup.d.ts.map +1 -1
  34. package/dist/startup.js +3 -3
  35. package/dist/startup.js.map +1 -1
  36. package/package.json +3 -3
  37. package/src/audit.ts +6 -3
  38. package/src/cli.ts +41 -1
  39. package/src/gateway.ts +238 -13
  40. package/src/index.ts +1 -0
  41. package/src/init/index.ts +3 -0
  42. package/src/init/presets.ts +64 -0
  43. package/src/init/run.ts +79 -0
  44. package/src/init/suggest.ts +127 -0
  45. package/src/startup.ts +3 -3
package/src/gateway.ts CHANGED
@@ -5,6 +5,11 @@
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
15
  * `isError: true` explaining why, so the model can tell the user.
@@ -20,10 +25,24 @@
20
25
  import { randomUUID } from "node:crypto";
21
26
  import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
27
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
28
+ import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
23
29
  import {
24
30
  CallToolRequestSchema,
31
+ ErrorCode,
32
+ GetPromptRequestSchema,
33
+ ListPromptsRequestSchema,
34
+ ListResourcesRequestSchema,
35
+ ListResourceTemplatesRequestSchema,
25
36
  ListToolsRequestSchema,
37
+ McpError,
38
+ ReadResourceRequestSchema,
26
39
  type CallToolResult,
40
+ type GetPromptResult,
41
+ type Prompt,
42
+ type ReadResourceResult,
43
+ type Resource,
44
+ type ResourceTemplate,
45
+ type ServerCapabilities,
27
46
  type Tool,
28
47
  } from "@modelcontextprotocol/sdk/types.js";
29
48
  import type { InvocationContext } from "@adtl/interceptors";
@@ -32,7 +51,7 @@ import type { AuditEntry, AuditSink } from "./audit.js";
32
51
  import { TOOL_SEPARATOR } from "./config.js";
33
52
  import type { Blocked, ChainOutcome, InterceptorInvoker } from "./invoker.js";
34
53
 
35
- export const GATEWAY_VERSION = "0.2.0";
54
+ export const GATEWAY_VERSION = "0.3.0";
36
55
 
37
56
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
38
57
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -43,6 +62,21 @@ interface Route {
43
62
  annotations?: Tool["annotations"];
44
63
  }
45
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
+
46
80
  export interface GatewayLogger {
47
81
  info(msg: string): void;
48
82
  warn(msg: string): void;
@@ -110,6 +144,10 @@ export class AdtlGateway {
110
144
  private readonly log: GatewayLogger;
111
145
  private readonly approvals: ApprovalBroker | undefined;
112
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[] = [];
113
151
 
114
152
  constructor(opts: AdtlGatewayOptions) {
115
153
  this.upstreams = opts.upstreams;
@@ -122,10 +160,15 @@ export class AdtlGateway {
122
160
 
123
161
  /** The downstream MCP server the agent connects to. */
124
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 = {};
125
168
  const server = new Server(
126
169
  { name: "adtl-gateway", version: GATEWAY_VERSION },
127
170
  {
128
- capabilities: { tools: {} },
171
+ capabilities,
129
172
  instructions:
130
173
  "Tools from several MCP servers, named <server>__<tool>. ADTL checks each call against trust labels; " +
131
174
  "if a call is stopped, the result explains why.",
@@ -141,32 +184,214 @@ export class AdtlGateway {
141
184
  extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, message } });
142
185
  return this.callTool(req.params.name, req.params.arguments ?? {}, progress ? { progress } : {});
143
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
+ }
144
198
  return server;
145
199
  }
146
200
 
147
- async listTools(): Promise<Tool[]> {
148
- const routes = new Map<string, Route>();
149
- 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 }[] = [];
150
208
  for (const [upstream, client] of this.upstreams) {
209
+ if (capability !== "tools" && !client.getServerCapabilities()?.[capability]) continue;
151
210
  let cursor: string | undefined;
152
211
  try {
153
212
  do {
154
- const page = await client.listTools(cursor ? { cursor } : {});
155
- for (const t of page.tools) {
156
- const name = `${upstream}${TOOL_SEPARATOR}${t.name}`;
157
- routes.set(name, { upstream, tool: t.name, ...(t.annotations ? { annotations: t.annotations } : {}) });
158
- tools.push({ ...t, name });
159
- }
160
- 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;
161
216
  } while (cursor);
162
217
  } catch (err) {
163
- this.log.warn(`tools/list failed for ${upstream}: ${(err as Error).message}`);
218
+ this.log.warn(`${method} failed for ${upstream}: ${(err as Error).message}`);
164
219
  }
165
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
+ });
166
235
  this.routes = routes;
167
236
  return tools;
168
237
  }
169
238
 
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
+
170
395
  async callTool(name: string, args: Record<string, unknown>, opts: CallOptions = {}): Promise<CallToolResult> {
171
396
  const started = performance.now();
172
397
  let route = this.routes.get(name);
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@ export * from "./ntfy.js";
6
6
  export * from "./email.js";
7
7
  export * from "./gateway.js";
8
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
+ }
@@ -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/startup.ts CHANGED
@@ -88,7 +88,7 @@ function stdioTransport(u: StdioUpstream, baseDir: string): StdioClientTransport
88
88
  });
89
89
  }
90
90
 
91
- async function connect(id: string, u: Upstream, baseDir: string): Promise<Client> {
91
+ export async function connectUpstream(id: string, u: Upstream, baseDir: string): Promise<Client> {
92
92
  const client = new Client({ name: `adtl-gateway→${id}`, version: GATEWAY_VERSION });
93
93
  const transport = isHttpUpstream(u)
94
94
  ? new StreamableHTTPClientTransport(new URL(u.url), { requestInit: { headers: u.headers } })
@@ -137,7 +137,7 @@ export async function startGateway(
137
137
  const icfg: StdioUpstream = loaded.gateway.interceptors
138
138
  ? expandUpstream("interceptors", loaded.gateway.interceptors, env)
139
139
  : { command: process.execPath, args: [bundledInterceptorsCli(), "--config", loaded.path], env: {} };
140
- const interceptors = await connect("interceptors", icfg, baseDir);
140
+ const interceptors = await connectUpstream("interceptors", icfg, baseDir);
141
141
  clients.push(interceptors);
142
142
  if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
143
143
  throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
@@ -158,7 +158,7 @@ export async function startGateway(
158
158
  const upstreams = new Map<string, Client>();
159
159
  const ids = Object.keys(loaded.gateway.upstreams);
160
160
  const settled = await Promise.allSettled(
161
- 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)),
162
162
  );
163
163
  settled.forEach((s, i) => {
164
164
  const id = ids[i]!;