@adtl/gateway 0.2.0 → 0.3.1

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 (65) hide show
  1. package/README.md +63 -3
  2. package/dist/audit.d.ts +9 -4
  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 +82 -2
  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/hooks/claudeCode.d.ts +43 -0
  13. package/dist/hooks/claudeCode.d.ts.map +1 -0
  14. package/dist/hooks/claudeCode.js +148 -0
  15. package/dist/hooks/claudeCode.js.map +1 -0
  16. package/dist/hooks/index.d.ts +4 -0
  17. package/dist/hooks/index.d.ts.map +1 -0
  18. package/dist/hooks/index.js +4 -0
  19. package/dist/hooks/index.js.map +1 -0
  20. package/dist/hooks/preset.d.ts +16 -0
  21. package/dist/hooks/preset.d.ts.map +1 -0
  22. package/dist/hooks/preset.js +32 -0
  23. package/dist/hooks/preset.js.map +1 -0
  24. package/dist/hooks/run.d.ts +9 -0
  25. package/dist/hooks/run.d.ts.map +1 -0
  26. package/dist/hooks/run.js +44 -0
  27. package/dist/hooks/run.js.map +1 -0
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/init/index.d.ts +4 -0
  33. package/dist/init/index.d.ts.map +1 -0
  34. package/dist/init/index.js +4 -0
  35. package/dist/init/index.js.map +1 -0
  36. package/dist/init/presets.d.ts +30 -0
  37. package/dist/init/presets.d.ts.map +1 -0
  38. package/dist/init/presets.js +34 -0
  39. package/dist/init/presets.js.map +1 -0
  40. package/dist/init/run.d.ts +18 -0
  41. package/dist/init/run.d.ts.map +1 -0
  42. package/dist/init/run.js +60 -0
  43. package/dist/init/run.js.map +1 -0
  44. package/dist/init/suggest.d.ts +50 -0
  45. package/dist/init/suggest.d.ts.map +1 -0
  46. package/dist/init/suggest.js +82 -0
  47. package/dist/init/suggest.js.map +1 -0
  48. package/dist/startup.d.ts +13 -2
  49. package/dist/startup.d.ts.map +1 -1
  50. package/dist/startup.js +25 -6
  51. package/dist/startup.js.map +1 -1
  52. package/package.json +3 -3
  53. package/src/audit.ts +10 -5
  54. package/src/cli.ts +75 -2
  55. package/src/gateway.ts +238 -13
  56. package/src/hooks/claudeCode.ts +194 -0
  57. package/src/hooks/index.ts +3 -0
  58. package/src/hooks/preset.ts +47 -0
  59. package/src/hooks/run.ts +49 -0
  60. package/src/index.ts +2 -0
  61. package/src/init/index.ts +3 -0
  62. package/src/init/presets.ts +64 -0
  63. package/src/init/run.ts +79 -0
  64. package/src/init/suggest.ts +127 -0
  65. package/src/startup.ts +25 -7
@@ -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
@@ -9,7 +9,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
9
  import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
10
10
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
11
  import { INTERCEPTORS_EXTENSION, LABEL_INTERCEPTOR, POLICY_INTERCEPTOR, parseConfigText } from "@adtl/interceptors";
12
- import type { AdtlConfig } from "@adtl/labels";
12
+ import { defaultSessionsDir, type AdtlConfig } from "@adtl/labels";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { ZodError } from "zod";
15
15
  import { ApprovalBroker, type Notifier } from "./approvals.js";
@@ -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 } })
@@ -117,6 +117,16 @@ export function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log:
117
117
  return new ApprovalBroker({ notifiers, timeoutMs: a.timeoutSeconds * 1000, log });
118
118
  }
119
119
 
120
+ /**
121
+ * Under Claude Code, which passes `CLAUDE_CODE_SESSION_ID` to stdio MCP
122
+ * servers, the gateway uses that id and keeps session labels in files, so the
123
+ * Claude Code hooks (`adtl-gateway hook`) add to the same session label.
124
+ */
125
+ export function sharedSession(env: NodeJS.ProcessEnv): { sessionId: string; dir: string } | undefined {
126
+ const sessionId = env.CLAUDE_CODE_SESSION_ID?.trim();
127
+ return sessionId ? { sessionId, dir: defaultSessionsDir(env) } : undefined;
128
+ }
129
+
120
130
  export interface StartedGateway {
121
131
  gateway: AdtlGateway;
122
132
  close(): Promise<void>;
@@ -134,10 +144,16 @@ export async function startGateway(
134
144
 
135
145
  try {
136
146
  // 1. Interceptor server (out of process, per the Interceptors WG).
137
- const icfg: StdioUpstream = loaded.gateway.interceptors
138
- ? expandUpstream("interceptors", loaded.gateway.interceptors, env)
139
- : { command: process.execPath, args: [bundledInterceptorsCli(), "--config", loaded.path], env: {} };
140
- const interceptors = await connect("interceptors", icfg, baseDir);
147
+ const shared = sharedSession(env);
148
+ const custom = loaded.gateway.interceptors && expandUpstream("interceptors", loaded.gateway.interceptors, env);
149
+ const icfg: StdioUpstream = custom
150
+ ? { ...custom, env: { ...custom.env, ...(shared ? { ADTL_SESSIONS_DIR: shared.dir } : {}) } }
151
+ : {
152
+ command: process.execPath,
153
+ args: [bundledInterceptorsCli(), "--config", loaded.path, ...(shared ? ["--sessions-dir", shared.dir] : [])],
154
+ env: {},
155
+ };
156
+ const interceptors = await connectUpstream("interceptors", icfg, baseDir);
141
157
  clients.push(interceptors);
142
158
  if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
143
159
  throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
@@ -152,13 +168,14 @@ export async function startGateway(
152
168
  const audit = loaded.gateway.mode === "audit";
153
169
  const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined);
154
170
  log.info(`interceptors: ${names.join(", ")}`);
171
+ if (shared) log.info(`sharing session labels with Claude Code hooks (${shared.dir})`);
155
172
  if (audit) log.warn("audit mode: calls are logged but never stopped or changed");
156
173
 
157
174
  // 2. Upstream servers. One failing upstream does not stop the others.
158
175
  const upstreams = new Map<string, Client>();
159
176
  const ids = Object.keys(loaded.gateway.upstreams);
160
177
  const settled = await Promise.allSettled(
161
- ids.map((id) => connect(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir)),
178
+ ids.map((id) => connectUpstream(id, expandUpstream(id, loaded.gateway.upstreams[id]!, env), baseDir)),
162
179
  );
163
180
  settled.forEach((s, i) => {
164
181
  const id = ids[i]!;
@@ -178,6 +195,7 @@ export async function startGateway(
178
195
  const auditLog = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
179
196
  const approvals = createApprovals(loaded.gateway, env, log);
180
197
  const gateway = new AdtlGateway({
198
+ ...(shared ? { sessionId: shared.sessionId } : {}),
181
199
  upstreams,
182
200
  invoker,
183
201
  ...(auditLog ? { audit: auditLog } : {}),