@zgeoff/atc 3.4.0 → 3.4.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 (63) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-agent-adapters.ts +21 -3
  3. package/src/agents/codex-adapter.ts +20 -0
  4. package/src/agents/render-sdk-message.ts +57 -0
  5. package/src/agents/start-claude-headless-run.ts +2 -57
  6. package/src/agents/with-claude-config-lock.ts +28 -15
  7. package/src/client/boot-daemon.ts +75 -25
  8. package/src/client/resolve-workspace-root.ts +10 -5
  9. package/src/daemon/session-runtime.ts +0 -4
  10. package/src/gateway.ts +2 -179
  11. package/src/mcp-http-server.ts +13 -10
  12. package/src/run-gateway-cli.ts +327 -0
  13. package/src/run-gateway.ts +49 -18
  14. package/src/sources/github/run-gh.ts +9 -0
  15. package/src/test-utils/build-mock-execution-target.ts +25 -0
  16. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  17. package/src/test-utils/build-mock-message-record.ts +7 -3
  18. package/src/test-utils/build-mock-stored-event.ts +20 -0
  19. package/src/test-utils/build-mock-stored-row.ts +23 -0
  20. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  21. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  22. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  23. package/src/test-utils/build-stub-recording-git.ts +8 -0
  24. package/src/test-utils/create-git-fixture.ts +5 -8
  25. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  26. package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +30 -5
  27. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  28. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  29. package/src/test-utils/create-stub-systemd.ts +10 -4
  30. package/src/test-utils/get-only-imp-name.ts +2 -2
  31. package/src/test-utils/register-test-cleanup.ts +63 -0
  32. package/src/test-utils/run-legacy-daemon.ts +1 -1
  33. package/src/test-utils/run-mcp-authorization.ts +7 -4
  34. package/src/test-utils/setup-mcp-home.ts +24 -13
  35. package/src/test-utils/setup-mcp-http.ts +15 -7
  36. package/src/test-utils/setup-temp-dir.ts +7 -6
  37. package/src/test-utils/start-cut-proxy.ts +6 -2
  38. package/src/test-utils/start-daemon-process.ts +25 -24
  39. package/src/test-utils/start-git-http-server.ts +9 -3
  40. package/src/test-utils/start-mcp-stdio.ts +17 -22
  41. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  42. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  43. package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
  44. package/src/test-utils/start-stub-impd-info.ts +10 -4
  45. package/src/test-utils/start-stub-impd.ts +8 -3
  46. package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
  47. package/src/test-utils/start-stub-mcp-server.ts +7 -2
  48. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  49. package/src/test-utils/start-stub-refusing-listener.ts +9 -4
  50. package/src/test-utils/start-stub-reporter-socket.ts +6 -4
  51. package/src/test-utils/start-stub-restore-daemon.ts +5 -4
  52. package/src/test-utils/start-stub-session-bridge.ts +6 -4
  53. package/src/test-utils/start-stub-silent-server.ts +10 -5
  54. package/src/test-utils/start-stub-source-daemon.ts +5 -3
  55. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  56. package/src/test-utils/start-stub-stalled-listener.ts +13 -9
  57. package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
  58. package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
  59. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  60. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  61. package/src/test-utils/start-test-daemon.ts +20 -6
  62. package/src/test-utils/start-tui-harness.ts +15 -8
  63. package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
package/src/gateway.ts CHANGED
@@ -1,183 +1,6 @@
1
- import { join } from 'node:path';
2
- import { defineCommand, runMain } from 'citty';
3
- import pkg from '../package.json';
4
- import { collectRedirectURIs } from './collect-redirect-uris';
5
- import { parseGatewayStateDir } from './parse-gateway-state-dir';
6
- import { parsePort } from './parse-port';
7
-
8
- // The flag every subcommand takes for the directory holding `gateway.db` and
9
- // `mcp-auth.db`.
10
- const STATE_DIR_ARG = {
11
- 'state-dir': {
12
- type: 'string',
13
- description: 'Directory for gateway.db and mcp-auth.db (default $ATC_GATEWAY_STATE_DIR)',
14
- },
15
- } as const;
16
-
17
- // Every flag a subcommand declares, so a flag in any position, or one no
18
- // subcommand declares, is caught before a subcommand runs.
19
- const GATEWAY_FLAGS = {
20
- values: new Set(['host', 'port', 'public-url', 'registry', 'redirect-uri', 'state-dir']),
21
- switches: new Set(['help', 'h', 'version']),
22
- };
23
-
24
- // The state directory comes from the whole command line, since the argument
25
- // parser hands a subcommand only the arguments after its name.
26
- const parsedStateDir = parseGatewayStateDir(process.argv.slice(2), process.env, GATEWAY_FLAGS);
27
-
28
- if (!parsedStateDir.ok) {
29
- console.error(`atc-gateway: ${parsedStateDir.message}`);
30
- process.exit(1);
31
- }
32
-
33
- const stateDir = parsedStateDir.stateDir;
34
- const NO_STATE_DIR = 'atc-gateway: give --state-dir or set ATC_GATEWAY_STATE_DIR';
1
+ import { runGatewayCLI } from './run-gateway-cli';
35
2
 
36
3
  // atc-gateway entry: serves the MCP tools over HTTP for the daemons a
37
4
  // registry lists, and manages the clients that may connect to it. It loads
38
5
  // nothing that starts a daemon or a session on this machine.
39
- const main = defineCommand({
40
- meta: {
41
- name: 'atc-gateway',
42
- version: pkg.version,
43
- description: 'Serve the atc MCP tools over HTTP for the daemons a registry lists',
44
- },
45
-
46
- // Declared at every level, so the argument parser reads `--state-dir <dir>`
47
- // before a subcommand as a flag and its value, not as a subcommand name.
48
- args: STATE_DIR_ARG,
49
- subCommands: {
50
- serve: () =>
51
- defineCommand({
52
- meta: { name: 'serve', description: 'Serve MCP over HTTP behind OAuth' },
53
- args: {
54
- host: { type: 'string', description: 'Address to bind (default 127.0.0.1)' },
55
- port: { type: 'string', description: 'Port to listen on (default 8414)' },
56
- 'public-url': {
57
- type: 'string',
58
- required: true,
59
- description: 'Origin clients reach the gateway at, such as https://mcp.example.com',
60
- },
61
- registry: { type: 'string', required: true, description: 'The registry JSON file' },
62
- ...STATE_DIR_ARG,
63
- },
64
- async run(ctx) {
65
- const port = ctx.args.port === undefined ? null : parsePort(ctx.args.port);
66
-
67
- if (port !== null && !port.ok) {
68
- console.error(`atc-gateway: ${port.message}`);
69
- process.exit(1);
70
- }
71
-
72
- if (stateDir === null) {
73
- console.error(NO_STATE_DIR);
74
- process.exit(1);
75
- }
76
-
77
- const gateway = await import('./run-gateway');
78
-
79
- await gateway.runGateway(`atc-gateway/${pkg.version}`, {
80
- host: ctx.args.host ?? '127.0.0.1',
81
- port: port === null ? 8414 : port.port,
82
- publicURL: ctx.args['public-url'],
83
- registryPath: ctx.args.registry,
84
- stateDir,
85
- });
86
- },
87
- }),
88
- clients: () =>
89
- defineCommand({
90
- meta: {
91
- name: 'clients',
92
- description: 'List, add, or remove the clients that may connect to the gateway',
93
- },
94
- args: STATE_DIR_ARG,
95
- default: 'list',
96
- subCommands: {
97
- list: () =>
98
- defineCommand({
99
- meta: { name: 'list', description: 'List the clients', hidden: true },
100
- args: STATE_DIR_ARG,
101
- async run() {
102
- if (stateDir === null) {
103
- console.error(NO_STATE_DIR);
104
- process.exit(1);
105
- }
106
-
107
- const clients = await import('./clients');
108
-
109
- await clients.runClients(
110
- { kind: 'list' },
111
- {
112
- dbPath: join(stateDir, 'mcp-auth.db'),
113
- command: 'atc-gateway clients',
114
- },
115
- );
116
- },
117
- }),
118
- add: () =>
119
- defineCommand({
120
- meta: { name: 'add', description: 'Add a client and print its client ID' },
121
- args: {
122
- name: { type: 'positional', required: true, description: 'The client name' },
123
- 'redirect-uri': {
124
- type: 'string',
125
- required: true,
126
- description: 'A redirect URI the client returns to; repeat for more',
127
- },
128
- ...STATE_DIR_ARG,
129
- },
130
- async run(ctx) {
131
- if (stateDir === null) {
132
- console.error(NO_STATE_DIR);
133
- process.exit(1);
134
- }
135
-
136
- const clients = await import('./clients');
137
-
138
- await clients.runClients(
139
- {
140
- kind: 'add',
141
- name: ctx.args.name,
142
- redirectURIs: collectRedirectURIs(ctx.rawArgs),
143
- },
144
- {
145
- dbPath: join(stateDir, 'mcp-auth.db'),
146
- command: 'atc-gateway clients',
147
- },
148
- );
149
- },
150
- }),
151
- remove: () =>
152
- defineCommand({
153
- meta: {
154
- name: 'remove',
155
- description: 'Remove a client and revoke every grant it holds',
156
- },
157
- args: {
158
- id: { type: 'positional', required: true, description: 'The client ID' },
159
- ...STATE_DIR_ARG,
160
- },
161
- async run(ctx) {
162
- if (stateDir === null) {
163
- console.error(NO_STATE_DIR);
164
- process.exit(1);
165
- }
166
-
167
- const clients = await import('./clients');
168
-
169
- await clients.runClients(
170
- { kind: 'remove', clientID: ctx.args.id },
171
- {
172
- dbPath: join(stateDir, 'mcp-auth.db'),
173
- command: 'atc-gateway clients',
174
- },
175
- );
176
- },
177
- }),
178
- },
179
- }),
180
- },
181
- });
182
-
183
- await runMain(main);
6
+ await runGatewayCLI(process.argv.slice(2), process.env);
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { bootDaemonClient } from './client/boot-daemon';
4
- import type { DaemonBoot, DaemonBootOptions } from './client/boot-daemon';
4
+ import type { DaemonBoot, DaemonPaths } from './client/boot-daemon';
5
5
  import { DaemonClient } from './client/daemon-client';
6
6
  import { collectClients } from './mcp/collect-clients';
7
7
  import { openMCPAuth } from './mcp/open-mcp-auth';
@@ -19,14 +19,14 @@ interface MCPHTTPFlags {
19
19
  }
20
20
 
21
21
  // What the server reaches outside itself, the process's own by default: its
22
- // config, the authorization database, the daemon it boots or waits for, the
23
- // console, the exit, and the signal listeners that stop it.
22
+ // config, the authorization database, the files that locate the daemon it
23
+ // boots or waits for (null for the process's own, the only ones at which it
24
+ // starts a daemon), the console, the exit, and the signal listeners that
25
+ // stop it.
24
26
  interface MCPHTTPServerIO {
25
27
  readonly loadConfig: () => MCPHTTPConfig;
26
28
  readonly dbPath: string;
27
- readonly bootDaemon: (
28
- options: DaemonBootOptions,
29
- ) => Promise<Pick<DaemonBoot, 'client' | 'socketPath'>>;
29
+ readonly daemonPaths: DaemonPaths | null;
30
30
  readonly print: (line: string) => void;
31
31
  readonly printError: (line: string) => void;
32
32
  readonly exit: (code: number) => void;
@@ -36,7 +36,7 @@ interface MCPHTTPServerIO {
36
36
  const PROCESS_IO: MCPHTTPServerIO = {
37
37
  loadConfig: loadMCPHTTPConfig,
38
38
  dbPath: mcpAuthDBFile,
39
- bootDaemon: bootDaemonClient,
39
+ daemonPaths: null,
40
40
  print: (line) => {
41
41
  console.log(line);
42
42
  },
@@ -70,7 +70,7 @@ export async function runMCPHTTPServer(
70
70
  ): Promise<void> {
71
71
  const config = io.loadConfig();
72
72
 
73
- const bootOptions = flags.waitForDaemon
73
+ const waitOptions = flags.waitForDaemon
74
74
  ? {
75
75
  waitForDaemonMs: DAEMON_WAIT_MS,
76
76
  onWaitForDaemon: () => {
@@ -81,10 +81,13 @@ export async function runMCPHTTPServer(
81
81
  }
82
82
  : {};
83
83
 
84
- let boot: Pick<DaemonBoot, 'client' | 'socketPath'>;
84
+ const bootOptions =
85
+ io.daemonPaths === null ? waitOptions : { ...waitOptions, paths: io.daemonPaths };
86
+
87
+ let boot: DaemonBoot;
85
88
 
86
89
  try {
87
- boot = await io.bootDaemon(bootOptions);
90
+ boot = await bootDaemonClient(bootOptions);
88
91
  } catch (error) {
89
92
  io.printError(`atc mcp --http: ${error instanceof Error ? error.message : String(error)}`);
90
93
  io.exit(1);
@@ -0,0 +1,327 @@
1
+ import { join } from 'node:path';
2
+ import { defineCommand, renderUsage, runCommand } from 'citty';
3
+ import pkg from '../package.json';
4
+ import type { runClients } from './clients';
5
+ import { collectRedirectURIs } from './collect-redirect-uris';
6
+ import { parseGatewayStateDir } from './parse-gateway-state-dir';
7
+ import { parsePort } from './parse-port';
8
+ import type { runGateway } from './run-gateway';
9
+
10
+ // What the command line reaches outside itself, the process's own by
11
+ // default: the subcommands it hands a parsed command to, the console, and
12
+ // the exit.
13
+ interface GatewayCLIIO {
14
+ readonly runGateway: (...args: Readonly<Parameters<typeof runGateway>>) => Promise<void>;
15
+ readonly runClients: (...args: Readonly<Parameters<typeof runClients>>) => Promise<void>;
16
+ readonly print: (line: string) => void;
17
+ readonly printError: (line: string) => void;
18
+ readonly exit: (code: number) => void;
19
+ }
20
+
21
+ const PROCESS_IO: GatewayCLIIO = {
22
+ runGateway: async (...args) => {
23
+ const gateway = await import('./run-gateway');
24
+
25
+ await gateway.runGateway(...args);
26
+ },
27
+ runClients: async (...args) => {
28
+ const clients = await import('./clients');
29
+
30
+ await clients.runClients(...args);
31
+ },
32
+ print: (line) => {
33
+ console.log(line);
34
+ },
35
+ printError: (line) => {
36
+ console.error(line);
37
+ },
38
+ exit: (code) => {
39
+ process.exit(code);
40
+ },
41
+ };
42
+
43
+ // Every flag a subcommand declares, so a flag in any position, or one no
44
+ // subcommand declares, is caught before a subcommand runs.
45
+ const GATEWAY_FLAGS = {
46
+ values: new Set(['host', 'port', 'public-url', 'registry', 'redirect-uri', 'state-dir']),
47
+ switches: new Set(['help', 'h', 'version']),
48
+ };
49
+
50
+ /**
51
+ * Runs the `atc-gateway` command line: reads the state directory from the
52
+ * whole of it, since the argument parser hands a subcommand only the
53
+ * arguments after its name, then parses the rest and runs the subcommand
54
+ * it names. A state directory that cannot be read prints the reason and
55
+ * exits 1 before any subcommand runs. `--help` anywhere prints the usage of
56
+ * the command it follows and exits 0, `--version` alone prints the version,
57
+ * and a command line the parser refuses prints that command's usage and
58
+ * the reason, then exits 1.
59
+ */
60
+ export async function runGatewayCLI(
61
+ argv: readonly string[],
62
+ env: Readonly<Record<string, string | undefined>>,
63
+ io: GatewayCLIIO = PROCESS_IO,
64
+ ): Promise<void> {
65
+ const parsed = parseGatewayStateDir(argv, env, GATEWAY_FLAGS);
66
+
67
+ if (!parsed.ok) {
68
+ io.printError(`atc-gateway: ${parsed.message}`);
69
+ io.exit(1);
70
+
71
+ return;
72
+ }
73
+
74
+ const gateway = buildGatewayCommand(parsed.stateDir, io);
75
+ const renderNamedUsage = pickUsage(gateway.usages, argv);
76
+
77
+ if (argv.includes('--help') || argv.includes('-h')) {
78
+ const usage = await renderNamedUsage();
79
+
80
+ io.print(`${usage}\n`);
81
+ io.exit(0);
82
+
83
+ return;
84
+ }
85
+
86
+ if (argv.length === 1 && argv[0] === '--version') {
87
+ io.print(pkg.version);
88
+
89
+ return;
90
+ }
91
+
92
+ try {
93
+ await runCommand(gateway.command, { rawArgs: [...argv] });
94
+ } catch (error) {
95
+ if (isUsageError(error)) {
96
+ const usage = await renderNamedUsage();
97
+
98
+ io.print(`${usage}\n`);
99
+ }
100
+
101
+ const message = error instanceof Error ? error.message : String(error);
102
+
103
+ io.printError(message);
104
+ io.exit(1);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The usage renderer of the deepest command the command line names: each
110
+ * word past the flags and their values, joined to the ones before it, while
111
+ * the joined name has a usage.
112
+ */
113
+ function pickUsage(
114
+ usages: Readonly<Record<string, () => Promise<string>>>,
115
+ argv: readonly string[],
116
+ ): () => Promise<string> {
117
+ let name = '';
118
+ let index = 0;
119
+
120
+ while (index < argv.length) {
121
+ const token = argv[index] ?? '';
122
+
123
+ index += 1;
124
+
125
+ if (token.startsWith('-')) {
126
+ // A value flag without `=` takes the next token as its value.
127
+ if (!token.includes('=') && GATEWAY_FLAGS.values.has(token.replace(/^-+/u, ''))) {
128
+ index += 1;
129
+ }
130
+
131
+ continue;
132
+ }
133
+
134
+ const longer = name === '' ? token : `${name} ${token}`;
135
+
136
+ if (usages[longer] === undefined) {
137
+ break;
138
+ }
139
+
140
+ name = longer;
141
+ }
142
+
143
+ return usages[name] ?? usages[''] ?? (() => Promise.resolve(''));
144
+ }
145
+
146
+ // The codes citty gives the errors it throws for a command line it refuses;
147
+ // citty exports no error class to test against.
148
+ const USAGE_ERROR_CODES: ReadonlySet<string> = new Set([
149
+ 'EARG',
150
+ 'E_UNKNOWN_COMMAND',
151
+ 'E_NO_COMMAND',
152
+ ]);
153
+
154
+ function isUsageError(error: unknown): boolean {
155
+ return (
156
+ error instanceof Error &&
157
+ 'code' in error &&
158
+ typeof error.code === 'string' &&
159
+ USAGE_ERROR_CODES.has(error.code)
160
+ );
161
+ }
162
+
163
+ // The flag every subcommand takes for the directory holding `gateway.db` and
164
+ // `mcp-auth.db`.
165
+ const STATE_DIR_ARG = {
166
+ 'state-dir': {
167
+ type: 'string',
168
+ description: 'Directory for gateway.db and mcp-auth.db (default $ATC_GATEWAY_STATE_DIR)',
169
+ },
170
+ } as const;
171
+
172
+ const NO_STATE_DIR = 'atc-gateway: give --state-dir or set ATC_GATEWAY_STATE_DIR';
173
+
174
+ const ROOT_META = {
175
+ name: 'atc-gateway',
176
+ version: pkg.version,
177
+ description: 'Serve the atc MCP tools over HTTP for the daemons a registry lists',
178
+ };
179
+
180
+ const CLIENTS_META = {
181
+ name: 'clients',
182
+ description: 'List, add, or remove the clients that may connect to the gateway',
183
+ };
184
+
185
+ /**
186
+ * The `atc-gateway` command tree, which serves the MCP tools over HTTP for
187
+ * the daemons a registry lists and manages the clients that may connect to
188
+ * it, and the usage of each command in it, keyed by the subcommand names
189
+ * that lead to it. Every subcommand takes the state directory read from
190
+ * the whole command line, or exits 1 without one.
191
+ */
192
+ function buildGatewayCommand(stateDir: string | null, io: GatewayCLIIO) {
193
+ const serve = defineCommand({
194
+ meta: { name: 'serve', description: 'Serve MCP over HTTP behind OAuth' },
195
+ args: {
196
+ host: { type: 'string', description: 'Address to bind (default 127.0.0.1)' },
197
+ port: { type: 'string', description: 'Port to listen on (default 8414)' },
198
+ 'public-url': {
199
+ type: 'string',
200
+ required: true,
201
+ description: 'Origin clients reach the gateway at, such as https://mcp.example.com',
202
+ },
203
+ registry: { type: 'string', required: true, description: 'The registry JSON file' },
204
+ ...STATE_DIR_ARG,
205
+ },
206
+ async run(ctx) {
207
+ const port = ctx.args.port === undefined ? null : parsePort(ctx.args.port);
208
+
209
+ if (port !== null && !port.ok) {
210
+ io.printError(`atc-gateway: ${port.message}`);
211
+ io.exit(1);
212
+
213
+ return;
214
+ }
215
+
216
+ if (stateDir === null) {
217
+ io.printError(NO_STATE_DIR);
218
+ io.exit(1);
219
+
220
+ return;
221
+ }
222
+
223
+ await io.runGateway(`atc-gateway/${pkg.version}`, {
224
+ host: ctx.args.host ?? '127.0.0.1',
225
+ port: port === null ? 8414 : port.port,
226
+ publicURL: ctx.args['public-url'],
227
+ registryPath: ctx.args.registry,
228
+ stateDir,
229
+ });
230
+ },
231
+ });
232
+
233
+ const list = defineCommand({
234
+ meta: { name: 'list', description: 'List the clients', hidden: true },
235
+ args: STATE_DIR_ARG,
236
+ async run() {
237
+ if (stateDir === null) {
238
+ io.printError(NO_STATE_DIR);
239
+ io.exit(1);
240
+
241
+ return;
242
+ }
243
+
244
+ await io.runClients(
245
+ { kind: 'list' },
246
+ { dbPath: join(stateDir, 'mcp-auth.db'), command: 'atc-gateway clients' },
247
+ );
248
+ },
249
+ });
250
+
251
+ const add = defineCommand({
252
+ meta: { name: 'add', description: 'Add a client and print its client ID' },
253
+ args: {
254
+ name: { type: 'positional', required: true, description: 'The client name' },
255
+ 'redirect-uri': {
256
+ type: 'string',
257
+ required: true,
258
+ description: 'A redirect URI the client returns to; repeat for more',
259
+ },
260
+ ...STATE_DIR_ARG,
261
+ },
262
+ async run(ctx) {
263
+ if (stateDir === null) {
264
+ io.printError(NO_STATE_DIR);
265
+ io.exit(1);
266
+
267
+ return;
268
+ }
269
+
270
+ await io.runClients(
271
+ { kind: 'add', name: ctx.args.name, redirectURIs: collectRedirectURIs(ctx.rawArgs) },
272
+ { dbPath: join(stateDir, 'mcp-auth.db'), command: 'atc-gateway clients' },
273
+ );
274
+ },
275
+ });
276
+
277
+ const remove = defineCommand({
278
+ meta: { name: 'remove', description: 'Remove a client and revoke every grant it holds' },
279
+ args: {
280
+ id: { type: 'positional', required: true, description: 'The client ID' },
281
+ ...STATE_DIR_ARG,
282
+ },
283
+ async run(ctx) {
284
+ if (stateDir === null) {
285
+ io.printError(NO_STATE_DIR);
286
+ io.exit(1);
287
+
288
+ return;
289
+ }
290
+
291
+ await io.runClients(
292
+ { kind: 'remove', clientID: ctx.args.id },
293
+ { dbPath: join(stateDir, 'mcp-auth.db'), command: 'atc-gateway clients' },
294
+ );
295
+ },
296
+ });
297
+
298
+ const clients = defineCommand({
299
+ meta: CLIENTS_META,
300
+ args: STATE_DIR_ARG,
301
+ default: 'list',
302
+ subCommands: { list, add, remove },
303
+ });
304
+
305
+ const command = defineCommand({
306
+ meta: ROOT_META,
307
+
308
+ // Declared at every level, so the argument parser reads `--state-dir <dir>`
309
+ // before a subcommand as a flag and its value, not as a subcommand name.
310
+ args: STATE_DIR_ARG,
311
+ subCommands: { serve, clients },
312
+ });
313
+
314
+ // Usage names a command after its parent, so each renders under a parent
315
+ // holding only that parent's name.
316
+ return {
317
+ command,
318
+ usages: {
319
+ '': () => renderUsage(command),
320
+ serve: () => renderUsage(serve, { meta: ROOT_META }),
321
+ clients: () => renderUsage(clients, { meta: ROOT_META }),
322
+ 'clients list': () => renderUsage(list, { meta: CLIENTS_META }),
323
+ 'clients add': () => renderUsage(add, { meta: CLIENTS_META }),
324
+ 'clients remove': () => renderUsage(remove, { meta: CLIENTS_META }),
325
+ },
326
+ };
327
+ }
@@ -14,6 +14,35 @@ interface GatewayFlags {
14
14
  readonly stateDir: string;
15
15
  }
16
16
 
17
+ // What the gateway reaches outside itself, the process's own by default:
18
+ // the environment its daemon tokens come from, the console, the exit, and
19
+ // the signal listeners that stop it.
20
+ interface GatewayIO {
21
+ readonly env: Readonly<Record<string, string | undefined>>;
22
+ readonly print: (line: string) => void;
23
+ readonly printError: (line: string) => void;
24
+ readonly exit: (code: number) => void;
25
+ readonly registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => Promise<void>) => void;
26
+ }
27
+
28
+ const PROCESS_IO: GatewayIO = {
29
+ env: process.env,
30
+ print: (line) => {
31
+ console.log(line);
32
+ },
33
+ printError: (line) => {
34
+ console.error(line);
35
+ },
36
+ exit: (code) => {
37
+ process.exit(code);
38
+ },
39
+ registerSignal: (signal, listener) => {
40
+ process.on(signal, () => {
41
+ void listener();
42
+ });
43
+ },
44
+ };
45
+
17
46
  /**
18
47
  * Runs `atc-gateway` in the foreground: serves the MCP tools over HTTP for
19
48
  * the daemons the registry lists, routing every call to one of them over
@@ -23,15 +52,21 @@ interface GatewayFlags {
23
52
  * registry that fails to load, or a server that fails to start, prints the
24
53
  * reason and exits 1.
25
54
  */
26
- export async function runGateway(build: string, flags: GatewayFlags): Promise<void> {
27
- const loaded = loadGatewayRegistry(flags.registryPath, process.env);
55
+ export async function runGateway(
56
+ build: string,
57
+ flags: GatewayFlags,
58
+ io: GatewayIO = PROCESS_IO,
59
+ ): Promise<void> {
60
+ const loaded = loadGatewayRegistry(flags.registryPath, io.env);
28
61
 
29
62
  if (!loaded.ok) {
30
63
  for (const error of loaded.errors) {
31
- console.error(`atc-gateway: ${error}`);
64
+ io.printError(`atc-gateway: ${error}`);
32
65
  }
33
66
 
34
- process.exit(1);
67
+ io.exit(1);
68
+
69
+ return;
35
70
  }
36
71
 
37
72
  mkdirSync(flags.stateDir, { recursive: true });
@@ -59,19 +94,21 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
59
94
  // The line carries a client's name, so control and format characters
60
95
  // are dropped before it reaches the log.
61
96
  printApproval: (line) => {
62
- console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
97
+ io.print(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
63
98
  },
64
99
 
65
100
  // Request lines go to stderr, so stdout keeps the approval lines alone.
66
101
  printRequest: (line) => {
67
- console.error(line);
102
+ io.printError(line);
68
103
  },
69
104
  });
70
105
  } catch (error) {
71
106
  await gateway.stop();
72
107
 
73
- console.error(`atc-gateway: ${error instanceof Error ? error.message : String(error)}`);
74
- process.exit(1);
108
+ io.printError(`atc-gateway: ${error instanceof Error ? error.message : String(error)}`);
109
+ io.exit(1);
110
+
111
+ return;
75
112
  }
76
113
 
77
114
  // The handlers go in before the serving line, so a signal sent once the
@@ -80,16 +117,10 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
80
117
  await server.stop();
81
118
  await gateway.stop();
82
119
 
83
- process.exit(0);
120
+ io.exit(0);
84
121
  };
85
122
 
86
- process.on('SIGINT', () => {
87
- void stopServing();
88
- });
89
-
90
- process.on('SIGTERM', () => {
91
- void stopServing();
92
- });
93
-
94
- console.log(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
123
+ io.registerSignal('SIGINT', stopServing);
124
+ io.registerSignal('SIGTERM', stopServing);
125
+ io.print(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
95
126
  }