@zgeoff/atc 3.4.0 → 3.4.2
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 +2 -2
- package/src/agents/build-agent-adapters.ts +21 -3
- package/src/agents/claude-adapter.ts +4 -3
- package/src/agents/codex-adapter.ts +20 -0
- package/src/agents/gateway-adapter.ts +2 -2
- package/src/agents/plan-claude-line-input.ts +44 -0
- package/src/agents/render-sdk-message.ts +57 -0
- package/src/agents/start-claude-headless-run.ts +2 -57
- package/src/agents/with-claude-config-lock.ts +28 -15
- package/src/client/boot-daemon.ts +75 -25
- package/src/client/resolve-workspace-root.ts +10 -5
- package/src/daemon/session-runtime.ts +0 -4
- package/src/gateway.ts +2 -179
- package/src/mcp/mcp-tools.ts +1 -1
- package/src/mcp-http-server.ts +13 -10
- package/src/run-gateway-cli.ts +327 -0
- package/src/run-gateway.ts +49 -18
- package/src/sources/github/run-gh.ts +9 -0
- package/src/test-utils/build-mock-execution-target.ts +25 -0
- package/src/test-utils/build-mock-imp-session-request.ts +53 -0
- package/src/test-utils/build-mock-message-record.ts +7 -3
- package/src/test-utils/build-mock-stored-event.ts +20 -0
- package/src/test-utils/build-mock-stored-row.ts +23 -0
- package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
- package/src/test-utils/build-stub-held-gh.ts +5 -7
- package/src/test-utils/build-stub-held-zoxide.ts +4 -6
- package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
- package/src/test-utils/build-stub-recording-filter.ts +9 -0
- package/src/test-utils/build-stub-recording-git.ts +8 -0
- package/src/test-utils/create-git-fixture.ts +5 -8
- package/src/test-utils/create-stub-claude.ts +3 -1
- package/src/test-utils/create-stub-echo-claude.ts +21 -0
- package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +39 -5
- package/src/test-utils/create-stub-recording-codex.ts +20 -0
- package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
- package/src/test-utils/create-stub-systemd.ts +10 -4
- package/src/test-utils/get-only-imp-name.ts +2 -2
- package/src/test-utils/register-test-cleanup.ts +63 -0
- package/src/test-utils/run-mcp-authorization.ts +7 -4
- package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +2 -2
- package/src/test-utils/setup-mcp-home.ts +24 -13
- package/src/test-utils/setup-mcp-http.ts +15 -7
- package/src/test-utils/setup-temp-dir.ts +7 -6
- package/src/test-utils/start-cut-proxy.ts +6 -2
- package/src/test-utils/start-daemon-process.ts +25 -24
- package/src/test-utils/start-git-http-server.ts +9 -3
- package/src/test-utils/start-mcp-stdio.ts +17 -22
- package/src/test-utils/start-stub-answering-listener.ts +65 -0
- package/src/test-utils/start-stub-closing-listener.ts +31 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
- package/src/test-utils/start-stub-impd-info.ts +10 -4
- package/src/test-utils/start-stub-impd.ts +8 -3
- package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
- package/src/test-utils/start-stub-mcp-server.ts +7 -2
- package/src/test-utils/start-stub-recording-listener.ts +47 -0
- package/src/test-utils/start-stub-refusing-listener.ts +9 -4
- package/src/test-utils/start-stub-reporter-socket.ts +6 -4
- package/src/test-utils/start-stub-restore-daemon.ts +5 -4
- package/src/test-utils/start-stub-session-bridge.ts +6 -4
- package/src/test-utils/start-stub-silent-server.ts +10 -5
- package/src/test-utils/start-stub-source-daemon.ts +6 -4
- package/src/test-utils/start-stub-stalled-client.ts +61 -0
- package/src/test-utils/start-stub-stalled-listener.ts +13 -9
- package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
- package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
- package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
- package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
- package/src/test-utils/start-test-daemon.ts +20 -6
- package/src/test-utils/start-tui-harness.ts +15 -8
- package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
- /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
|
@@ -24,8 +24,6 @@ export class SessionRuntime {
|
|
|
24
24
|
|
|
25
25
|
detectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
26
26
|
|
|
27
|
-
bootTimer: ReturnType<typeof setTimeout> | undefined;
|
|
28
|
-
|
|
29
27
|
headlessRun: HeadlessRunHandle | null = null;
|
|
30
28
|
|
|
31
29
|
pendingEject: (() => void) | null = null;
|
|
@@ -65,11 +63,9 @@ export class SessionRuntime {
|
|
|
65
63
|
dispose(): void {
|
|
66
64
|
clearTimeout(this.resizeTimer);
|
|
67
65
|
clearTimeout(this.detectTimer);
|
|
68
|
-
clearTimeout(this.bootTimer);
|
|
69
66
|
|
|
70
67
|
this.resizeTimer = undefined;
|
|
71
68
|
this.detectTimer = undefined;
|
|
72
|
-
this.bootTimer = undefined;
|
|
73
69
|
this.screen?.stop();
|
|
74
70
|
this.screen = null;
|
|
75
71
|
|
package/src/gateway.ts
CHANGED
|
@@ -1,183 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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);
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -428,7 +428,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
428
428
|
annotations: AGENT_FACING_DESTRUCTIVE,
|
|
429
429
|
scope: 'spawn',
|
|
430
430
|
description:
|
|
431
|
-
"Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
|
|
431
|
+
"Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. atc pastes the line, so its newlines stay in it, and Claude takes a line of about 800 characters or more as pasted text, not as typed words. On a Claude session, a line that starts with a slash command and an argument, such as /goal finish the release, has the command name typed and only the argument pasted, so the command runs at any length. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
|
|
432
432
|
inputSchema: {
|
|
433
433
|
type: 'object',
|
|
434
434
|
properties: {
|
package/src/mcp-http-server.ts
CHANGED
|
@@ -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,
|
|
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
|
|
23
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|