@zgeoff/atc 2.22.0 → 2.24.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.22.0",
3
+ "version": "2.24.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -30,6 +30,7 @@
30
30
  "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
31
31
  "test": "bash scripts/with-test-home.sh bun test",
32
32
  "test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 test/daemon-e2e.test.ts",
33
+ "test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000 src/gateway.test.ts",
33
34
  "test:atc-bridge": "bash scripts/with-test-home.sh bash scripts/test-atc-bridge.sh",
34
35
  "test:isolation": "bash scripts/check-test-isolation.sh",
35
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
package/src/cli.ts CHANGED
@@ -101,8 +101,12 @@ const main = defineCommand({
101
101
  meta: { name: 'list', description: 'List the clients', hidden: true },
102
102
  async run() {
103
103
  const clients = await import('./clients');
104
+ const config = await import('./shared/config');
104
105
 
105
- await clients.runClients({ kind: 'list' });
106
+ await clients.runClients(
107
+ { kind: 'list' },
108
+ { dbPath: config.mcpAuthDBFile, command: 'atc clients' },
109
+ );
106
110
  },
107
111
  }),
108
112
  add: () =>
@@ -118,12 +122,16 @@ const main = defineCommand({
118
122
  },
119
123
  async run(ctx) {
120
124
  const clients = await import('./clients');
121
-
122
- await clients.runClients({
123
- kind: 'add',
124
- name: ctx.args.name,
125
- redirectURIs: collectRedirectURIs(ctx.rawArgs),
126
- });
125
+ const config = await import('./shared/config');
126
+
127
+ await clients.runClients(
128
+ {
129
+ kind: 'add',
130
+ name: ctx.args.name,
131
+ redirectURIs: collectRedirectURIs(ctx.rawArgs),
132
+ },
133
+ { dbPath: config.mcpAuthDBFile, command: 'atc clients' },
134
+ );
127
135
  },
128
136
  }),
129
137
  remove: () =>
@@ -137,8 +145,12 @@ const main = defineCommand({
137
145
  },
138
146
  async run(ctx) {
139
147
  const clients = await import('./clients');
148
+ const config = await import('./shared/config');
140
149
 
141
- await clients.runClients({ kind: 'remove', clientID: ctx.args.id });
150
+ await clients.runClients(
151
+ { kind: 'remove', clientID: ctx.args.id },
152
+ { dbPath: config.mcpAuthDBFile, command: 'atc clients' },
153
+ );
142
154
  },
143
155
  }),
144
156
  },
package/src/clients.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
2
3
  import { collectClients } from './mcp/collect-clients';
3
4
  import { isAllowedRedirectURI } from './mcp/is-allowed-redirect-uri';
4
5
  import { openMCPAuth } from './mcp/open-mcp-auth';
5
6
  import { removeClient } from './mcp/remove-client';
6
- import { mcpAuthDBFile, stateDir } from './shared/config';
7
7
  import { normalizeClientName } from './shared/normalize-client-name';
8
8
 
9
9
  type ClientsAction =
@@ -11,30 +11,37 @@ type ClientsAction =
11
11
  | { readonly kind: 'add'; readonly name: string; readonly redirectURIs: readonly string[] }
12
12
  | { readonly kind: 'remove'; readonly clientID: string };
13
13
 
14
+ // The authorization server's database, and the command that prefixes every
15
+ // message, such as `atc clients`.
16
+ interface ClientsTarget {
17
+ readonly dbPath: string;
18
+ readonly command: string;
19
+ }
20
+
14
21
  /**
15
- * Runs `atc clients`: lists the clients that may connect to `atc mcp --http`,
16
- * adds one, or removes one along with every token and consent it holds. It
17
- * opens the authorization server's database directly, so it works whether or
18
- * not the server is running.
22
+ * Runs `atc clients` or `atc-gateway clients`: lists the clients that may
23
+ * connect to the MCP HTTP server, adds one, or removes one along with every
24
+ * token and consent it holds. It opens the authorization server's database
25
+ * directly, so it works whether or not the server is running.
19
26
  */
20
- export async function runClients(action: ClientsAction): Promise<void> {
27
+ export async function runClients(action: ClientsAction, target: ClientsTarget): Promise<void> {
21
28
  if (action.kind === 'add') {
22
29
  const refused = action.redirectURIs.find((uri) => !isAllowedRedirectURI(uri));
23
30
 
24
31
  if (action.redirectURIs.length === 0 || refused !== undefined) {
25
32
  const message =
26
33
  refused === undefined
27
- ? 'atc clients add: give at least one --redirect-uri'
28
- : `atc clients add: '${refused}' is not a redirect URI atc accepts; use https, or http on a loopback host, with no fragment`;
34
+ ? `${target.command} add: give at least one --redirect-uri`
35
+ : `${target.command} add: '${refused}' is not a redirect URI atc accepts; use https, or http on a loopback host, with no fragment`;
29
36
 
30
37
  console.error(message);
31
38
  process.exit(1);
32
39
  }
33
40
  }
34
41
 
35
- mkdirSync(stateDir, { recursive: true });
42
+ mkdirSync(dirname(target.dbPath), { recursive: true });
36
43
 
37
- const store = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
44
+ const store = await openMCPAuth({ dbPath: target.dbPath, origin: null });
38
45
 
39
46
  try {
40
47
  if (action.kind === 'add') {
@@ -53,7 +60,7 @@ export async function runClients(action: ClientsAction): Promise<void> {
53
60
  const removed = await removeClient(store.db, action.clientID);
54
61
 
55
62
  if (!removed) {
56
- console.error(`atc clients remove: no client has the ID '${action.clientID}'`);
63
+ console.error(`${target.command} remove: no client has the ID '${action.clientID}'`);
57
64
 
58
65
  process.exitCode = 1;
59
66
 
@@ -68,7 +75,7 @@ export async function runClients(action: ClientsAction): Promise<void> {
68
75
  const clients = await collectClients(store.db);
69
76
 
70
77
  if (clients.length === 0) {
71
- console.log('No clients. Add one with: atc clients add <name> --redirect-uri <uri>');
78
+ console.log(`No clients. Add one with: ${target.command} add <name> --redirect-uri <uri>`);
72
79
 
73
80
  return;
74
81
  }
package/src/gateway.ts ADDED
@@ -0,0 +1,183 @@
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';
35
+
36
+ // atc-gateway entry: serves the MCP tools over HTTP for the daemons a
37
+ // registry lists, and manages the clients that may connect to it. It loads
38
+ // 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);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The flags `atc-gateway` accepts anywhere on its command line: those that
3
+ * take a value, and switches that take none. Names are written without
4
+ * their leading dashes.
5
+ */
6
+ export interface GatewayFlags {
7
+ readonly values: ReadonlySet<string>;
8
+ readonly switches: ReadonlySet<string>;
9
+ }
10
+
11
+ type ParsedStateDir =
12
+ | { readonly ok: true; readonly stateDir: string | null }
13
+ | { readonly ok: false; readonly message: string };
14
+
15
+ // A flag: one or two dashes, a name, and an optional `=<value>`. A lone `-`
16
+ // is a positional.
17
+ const FLAG_PATTERN = /^--?(?<name>[^=]+)(?:=(?<value>[\s\S]*))?$/;
18
+
19
+ /**
20
+ * Finds the state directory in the whole `atc-gateway` command line, before,
21
+ * between, or after its subcommands, so no position can drop the flag.
22
+ * `--state-dir <dir>` and `--state-dir=<dir>` count; a value after `--` or
23
+ * held by another flag never does. Every `--state-dir` must give the same
24
+ * directory. The flag wins over `ATC_GATEWAY_STATE_DIR`, and with neither
25
+ * the result is null. A flag the gateway does not know, a switch given a
26
+ * value, and a flag whose value is missing or starts with `-` are refused,
27
+ * since reading them either way could pick the wrong directory.
28
+ */
29
+ export function parseGatewayStateDir(
30
+ argv: readonly string[],
31
+ env: Readonly<Record<string, string | undefined>>,
32
+ flags: GatewayFlags,
33
+ ): ParsedStateDir {
34
+ const given: string[] = [];
35
+
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const token = argv[i] ?? '';
38
+
39
+ if (token === '--') {
40
+ break;
41
+ }
42
+
43
+ const match = FLAG_PATTERN.exec(token);
44
+
45
+ if (match === null) {
46
+ continue;
47
+ }
48
+
49
+ const name = match.groups?.['name'] ?? '';
50
+ const inline = match.groups?.['value'];
51
+
52
+ if (flags.switches.has(name)) {
53
+ if (inline !== undefined) {
54
+ return { ok: false, message: `${token} takes no value` };
55
+ }
56
+
57
+ continue;
58
+ }
59
+
60
+ if (!flags.values.has(name)) {
61
+ return { ok: false, message: `unknown flag '${token}'` };
62
+ }
63
+
64
+ const value = inline ?? argv[i + 1];
65
+
66
+ if (inline === undefined) {
67
+ i++;
68
+ }
69
+
70
+ if (value === undefined || value === '' || (inline === undefined && value.startsWith('-'))) {
71
+ return { ok: false, message: `--${name} needs a value; write --${name}=<value>` };
72
+ }
73
+
74
+ if (name === 'state-dir') {
75
+ given.push(value);
76
+ }
77
+ }
78
+
79
+ const distinct = [...new Set(given)];
80
+
81
+ if (distinct.length > 1) {
82
+ return {
83
+ ok: false,
84
+ message: `--state-dir gives different directories: ${distinct.map((dir) => `'${dir}'`).join(', ')}`,
85
+ };
86
+ }
87
+
88
+ const fromEnv = env['ATC_GATEWAY_STATE_DIR'];
89
+
90
+ return {
91
+ ok: true,
92
+ stateDir: distinct[0] ?? (fromEnv === undefined || fromEnv === '' ? null : fromEnv),
93
+ };
94
+ }
@@ -0,0 +1,93 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { DaemonClient } from './client/daemon-client';
4
+ import { loadGatewayRegistry } from './federation/load-gateway-registry';
5
+ import { openGatewayCaller } from './federation/open-gateway-caller';
6
+ import { startMCPHTTPServer } from './mcp/start-mcp-http-server';
7
+ import type { MCPHTTPServer } from './mcp/start-mcp-http-server';
8
+
9
+ interface GatewayFlags {
10
+ readonly host: string;
11
+ readonly port: number;
12
+ readonly publicURL: string;
13
+ readonly registryPath: string;
14
+ readonly stateDir: string;
15
+ }
16
+
17
+ /**
18
+ * Runs `atc-gateway` in the foreground: serves the MCP tools over HTTP for
19
+ * the daemons the registry lists, routing every call to one of them over
20
+ * TCP, until SIGINT or SIGTERM. It never starts or reaches a local daemon.
21
+ * The keyed-request bindings (`gateway.db`) and the authorization server
22
+ * (`mcp-auth.db`) live in the state directory, the only place it writes. A
23
+ * registry that fails to load, or a server that fails to start, prints the
24
+ * reason and exits 1.
25
+ */
26
+ export async function runGateway(build: string, flags: GatewayFlags): Promise<void> {
27
+ const loaded = loadGatewayRegistry(flags.registryPath, process.env);
28
+
29
+ if (!loaded.ok) {
30
+ for (const error of loaded.errors) {
31
+ console.error(`atc-gateway: ${error}`);
32
+ }
33
+
34
+ process.exit(1);
35
+ }
36
+
37
+ mkdirSync(flags.stateDir, { recursive: true });
38
+
39
+ const gateway = openGatewayCaller({
40
+ registry: loaded.registry,
41
+ build,
42
+ openChannel: (address) => DaemonClient.open({ hostname: address.host, port: address.port }),
43
+ gatewayDBPath: join(flags.stateDir, 'gateway.db'),
44
+ });
45
+
46
+ let server: MCPHTTPServer;
47
+
48
+ try {
49
+ server = await startMCPHTTPServer({
50
+ caller: gateway.caller,
51
+ build,
52
+ host: flags.host,
53
+ port: flags.port,
54
+ publicURL: flags.publicURL,
55
+ allowedHosts: [],
56
+ dbPath: join(flags.stateDir, 'mcp-auth.db'),
57
+ probes: true,
58
+
59
+ // The line carries a client's name, so control and format characters
60
+ // are dropped before it reaches the log.
61
+ printApproval: (line) => {
62
+ console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
63
+ },
64
+
65
+ // Request lines go to stderr, so stdout keeps the approval lines alone.
66
+ printRequest: (line) => {
67
+ console.error(line);
68
+ },
69
+ });
70
+ } catch (error) {
71
+ await gateway.stop();
72
+
73
+ console.error(`atc-gateway: ${error instanceof Error ? error.message : String(error)}`);
74
+ process.exit(1);
75
+ }
76
+
77
+ console.log(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
78
+
79
+ const stopServing = async () => {
80
+ await server.stop();
81
+ await gateway.stop();
82
+
83
+ process.exit(0);
84
+ };
85
+
86
+ process.on('SIGINT', () => {
87
+ void stopServing();
88
+ });
89
+
90
+ process.on('SIGTERM', () => {
91
+ void stopServing();
92
+ });
93
+ }