@zgeoff/atc 2.8.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +5 -0
  2. package/package.json +6 -3
  3. package/src/agents/agent-adapter.ts +3 -1
  4. package/src/cli.ts +114 -4
  5. package/src/clients.ts +82 -0
  6. package/src/collect-redirect-uris.ts +16 -0
  7. package/src/daemon/build-fleet-events.ts +9 -0
  8. package/src/daemon/build-message-trail-entry.ts +54 -0
  9. package/src/daemon/build-report-trail-entry.ts +25 -0
  10. package/src/daemon/build-session-message-event.ts +0 -1
  11. package/src/daemon/daemon.ts +72 -11
  12. package/src/daemon/sessions.ts +13 -1
  13. package/src/daemon/start-headless-run.ts +3 -8
  14. package/src/daemon/start-headless-turn.ts +15 -3
  15. package/src/daemon/truncate-summary.ts +9 -0
  16. package/src/grants.ts +51 -0
  17. package/src/mcp/answer-authorize-request.ts +98 -0
  18. package/src/mcp/answer-consent-request.ts +166 -0
  19. package/src/mcp/answer-login-request.ts +108 -0
  20. package/src/mcp/answer-mcp-request.ts +161 -0
  21. package/src/mcp/answer-rpc-request.ts +137 -0
  22. package/src/mcp/approval-state.ts +155 -0
  23. package/src/mcp/build-consent-binding.ts +13 -0
  24. package/src/mcp/build-owner-plugin.ts +122 -0
  25. package/src/mcp/build-page-response.ts +29 -0
  26. package/src/mcp/build-tool-list.ts +21 -0
  27. package/src/mcp/collect-clients.ts +31 -0
  28. package/src/mcp/collect-grants.ts +55 -0
  29. package/src/mcp/collect-json-strings.ts +17 -0
  30. package/src/mcp/derive-token-hash.ts +9 -0
  31. package/src/mcp/find-client-name.ts +19 -0
  32. package/src/mcp/find-owner-session-id.ts +18 -0
  33. package/src/mcp/is-allowed-redirect-uri.ts +21 -0
  34. package/src/mcp/is-loopback-host.ts +13 -0
  35. package/src/mcp/is-supported-protocol-version.ts +13 -0
  36. package/src/mcp/mcp-tools.ts +278 -0
  37. package/src/mcp/mint-approval-code.ts +13 -0
  38. package/src/mcp/normalize-approval-code.ts +8 -0
  39. package/src/mcp/normalize-public-url.ts +33 -0
  40. package/src/mcp/open-mcp-auth.ts +145 -0
  41. package/src/mcp/pick-error-message.ts +22 -0
  42. package/src/mcp/pick-protocol-version.ts +9 -0
  43. package/src/mcp/reconnecting-caller.ts +121 -0
  44. package/src/mcp/remove-client.ts +36 -0
  45. package/src/mcp/render-consent-page.ts +65 -0
  46. package/src/mcp/render-login-page.ts +47 -0
  47. package/src/mcp/revoke-grant.ts +28 -0
  48. package/src/mcp/run-tool.ts +143 -0
  49. package/src/mcp/start-mcp-http-server.ts +330 -0
  50. package/src/mcp/to-html-text.ts +12 -0
  51. package/src/mcp/types.ts +78 -0
  52. package/src/mcp/verify-oauth-query.ts +42 -0
  53. package/src/mcp-http-server.ts +75 -0
  54. package/src/mcp-server.ts +22 -450
  55. package/src/parse-port.ts +18 -0
  56. package/src/shared/collect-mcp-http-config.ts +46 -0
  57. package/src/shared/config.ts +3 -1
  58. package/src/shared/grant-scope.ts +8 -0
  59. package/src/shared/load-mcp-http-config.ts +20 -0
  60. package/src/shared/normalize-client-name.ts +28 -0
  61. package/src/store/state-store.ts +65 -4
  62. package/src/store/trail-entry.ts +27 -0
@@ -0,0 +1,278 @@
1
+ import { z } from 'zod';
2
+ import { REQUEST_PARAM_SCHEMAS } from '../protocol/request-param-schemas';
3
+ import type { GrantScope } from '../shared/grant-scope';
4
+
5
+ const NO_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(z.strictObject({}));
6
+
7
+ /**
8
+ * The session id shape MCP tool schemas require: present and described.
9
+ * The daemon's wire request schemas share a defaulted-session shape
10
+ * instead, since they tolerate an absent session by defaulting it to an
11
+ * empty string.
12
+ */
13
+ const SESSION_ID_BASE = z.object({
14
+ session: z.string().describe('The atc session id, from atc_session_list'),
15
+ });
16
+
17
+ const SESSION_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(SESSION_ID_BASE.strict());
18
+ const SPAWN_SCHEMA = REQUEST_PARAM_SCHEMAS['session.spawn'];
19
+
20
+ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
21
+ z.strictObject({
22
+ cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
23
+ name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
24
+ prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
25
+ agent: SPAWN_SCHEMA.shape.agent.describe(
26
+ 'Which registered agent id to spawn; defaults to claude',
27
+ ),
28
+ detached: z
29
+ .boolean()
30
+ .optional()
31
+ .describe(
32
+ 'Spawn a top-level session. By default a spawn from inside an atc session becomes a sub-session of it: listed under it, pinned with it, killed with it.',
33
+ ),
34
+ }),
35
+ { io: 'input' },
36
+ );
37
+
38
+ const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
39
+ SESSION_ID_BASE.extend({
40
+ cursor: z
41
+ .string()
42
+ .optional()
43
+ .describe(
44
+ 'The cursor a previous atc_session_read returned; omit to read from the start of the conversation',
45
+ ),
46
+ limit: z
47
+ .number()
48
+ .int()
49
+ .min(1)
50
+ .max(200)
51
+ .optional()
52
+ .describe('Most rows to return; defaults to 50'),
53
+ }).strict(),
54
+ { io: 'input' },
55
+ );
56
+
57
+ const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
58
+ z.strictObject({
59
+ cursor: z
60
+ .string()
61
+ .optional()
62
+ .describe(
63
+ 'The cursor a previous atc_events_read returned; omit to get the most recent events',
64
+ ),
65
+ limit: z
66
+ .number()
67
+ .int()
68
+ .min(1)
69
+ .max(200)
70
+ .optional()
71
+ .describe('Most events to return; defaults to 50'),
72
+ waitMs: z
73
+ .number()
74
+ .int()
75
+ .min(0)
76
+ .max(30_000)
77
+ .optional()
78
+ .describe(
79
+ 'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
80
+ ),
81
+ }),
82
+ { io: 'input' },
83
+ );
84
+
85
+ interface MCPToolAnnotations {
86
+ readonly readOnlyHint: boolean;
87
+ readonly destructiveHint: boolean;
88
+ readonly openWorldHint: boolean;
89
+ }
90
+
91
+ interface MCPToolDefinition {
92
+ readonly name: string;
93
+ readonly description: string;
94
+ readonly inputSchema: Readonly<Record<string, unknown>>;
95
+ readonly annotations: MCPToolAnnotations;
96
+ readonly scope: GrantScope;
97
+ }
98
+
99
+ const READ_ONLY: MCPToolAnnotations = {
100
+ readOnlyHint: true,
101
+ destructiveHint: false,
102
+ openWorldHint: false,
103
+ };
104
+
105
+ const ADDITIVE: MCPToolAnnotations = {
106
+ readOnlyHint: false,
107
+ destructiveHint: false,
108
+ openWorldHint: false,
109
+ };
110
+
111
+ const DESTRUCTIVE: MCPToolAnnotations = {
112
+ readOnlyHint: false,
113
+ destructiveHint: true,
114
+ openWorldHint: false,
115
+ };
116
+
117
+ // A tool that starts an agent or puts text in front of one reaches past atc: the agent acts on
118
+ // what it reads, outside atc's control.
119
+ const AGENT_FACING: MCPToolAnnotations = {
120
+ readOnlyHint: false,
121
+ destructiveHint: false,
122
+ openWorldHint: true,
123
+ };
124
+
125
+ const AGENT_FACING_DESTRUCTIVE: MCPToolAnnotations = {
126
+ readOnlyHint: false,
127
+ destructiveHint: true,
128
+ openWorldHint: true,
129
+ };
130
+
131
+ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
132
+ {
133
+ name: 'atc_session_list',
134
+ annotations: READ_ONLY,
135
+ scope: 'read',
136
+ description:
137
+ 'List every session the atc daemon hosts: id, name, working directory, state (running, needs_you, done, exited), unread flag, and last activity.',
138
+ inputSchema: NO_INPUT,
139
+ },
140
+ {
141
+ name: 'atc_session_spawn',
142
+ annotations: AGENT_FACING,
143
+ scope: 'spawn',
144
+ description:
145
+ 'Spawn a new session in a directory. Optional agent is an agent id the daemon has registered, such as claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. An unregistered id is rejected. Called from inside an atc session, the new session is a sub-session of the caller unless detached is true. Returns the new session descriptor. Give it a prompt to start it working immediately.',
146
+ inputSchema: SPAWN_INPUT,
147
+ },
148
+ {
149
+ name: 'atc_session_input',
150
+ annotations: AGENT_FACING_DESTRUCTIVE,
151
+ scope: 'spawn',
152
+ description:
153
+ 'Type a line of text into a running session, as if the operator typed it and pressed enter. Use it to answer a session that is waiting on input.',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ session: { type: 'string', description: 'The atc session id' },
158
+ text: { type: 'string', description: 'The line to type; a newline is appended' },
159
+ },
160
+ required: ['session', 'text'],
161
+ additionalProperties: false,
162
+ },
163
+ },
164
+ {
165
+ name: 'atc_session_screen',
166
+ annotations: READ_ONLY,
167
+ scope: 'read',
168
+ description:
169
+ 'Read the current terminal screen of a session as plain text, without attaching to it. Use it to see what a session printed or what it is waiting on before answering it with atc_session_input. A killed session keeps its last screen.',
170
+ inputSchema: SESSION_INPUT,
171
+ },
172
+ {
173
+ name: 'atc_session_update',
174
+ annotations: ADDITIVE,
175
+ scope: 'message',
176
+ description:
177
+ 'Rename and/or pin a session. Renames stick against auto-summaries; pinned sessions lead every list. A sub-session pins with its parent, so pin the parent instead. Use this to organise the fleet: name sessions after their task.',
178
+ inputSchema: {
179
+ type: 'object',
180
+ properties: {
181
+ session: { type: 'string', description: 'The atc session id' },
182
+ name: { type: 'string', description: 'New display name; omit to keep' },
183
+ pinned: { type: 'boolean', description: 'Pin or unpin; omit to keep' },
184
+ },
185
+ required: ['session'],
186
+ additionalProperties: false,
187
+ },
188
+ },
189
+ {
190
+ name: 'atc_session_kill',
191
+ annotations: DESTRUCTIVE,
192
+ scope: 'kill',
193
+ description: 'Kill a session. A second kill on a dead session removes it from the list.',
194
+ inputSchema: SESSION_INPUT,
195
+ },
196
+ {
197
+ name: 'atc_session_ack',
198
+ annotations: ADDITIVE,
199
+ scope: 'message',
200
+ description: 'Clear a session unread flag without attaching to it.',
201
+ inputSchema: SESSION_INPUT,
202
+ },
203
+ {
204
+ name: 'atc_resume_command',
205
+ annotations: READ_ONLY,
206
+ scope: 'read',
207
+ description:
208
+ 'Build the shell command that reopens a session outside atc (cd into its directory and claude --resume its id).',
209
+ inputSchema: SESSION_INPUT,
210
+ },
211
+ {
212
+ name: 'atc_dirs_list',
213
+ annotations: READ_ONLY,
214
+ scope: 'read',
215
+ description: 'List directories sessions were previously spawned from, most recent first.',
216
+ inputSchema: NO_INPUT,
217
+ },
218
+ {
219
+ name: 'atc_session_get',
220
+ annotations: READ_ONLY,
221
+ scope: 'read',
222
+ description:
223
+ 'Read one session in a single call: its descriptor (state, unread flag, last activity message), the prompt it was spawned with, when it last reported activity, the prompt or question it is waiting on while it needs you (read-only; answer it with atc_session_input), and the final message of its latest finished turn.',
224
+ inputSchema: SESSION_INPUT,
225
+ },
226
+ {
227
+ name: 'atc_session_read',
228
+ annotations: READ_ONLY,
229
+ scope: 'read',
230
+ description:
231
+ "Read a session's conversation a page at a time, oldest first: user and assistant messages with tool uses summarised. Pass the returned cursor to continue where you left off; more is true when the page stopped before the end. Claude sessions only; other agents answer unsupported.",
232
+ inputSchema: SESSION_READ_INPUT,
233
+ },
234
+ {
235
+ name: 'atc_events_read',
236
+ annotations: READ_ONLY,
237
+ scope: 'read',
238
+ description:
239
+ 'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label. Without a cursor it returns the most recent events. Pass the returned cursor next time. waitMs holds the call open until an event arrives.',
240
+ inputSchema: EVENTS_READ_INPUT,
241
+ },
242
+ {
243
+ name: 'atc_session_message',
244
+ annotations: AGENT_FACING,
245
+ scope: 'message',
246
+ description:
247
+ "Send a session a message and get its id back. Follow up by polling atc_message_get with the id until its status is answered, which returns the session's final reply; don't read the session's screen or transcript to check on it. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
248
+ inputSchema: {
249
+ type: 'object',
250
+ properties: {
251
+ session: { type: 'string', description: 'The atc session id, from atc_session_list' },
252
+ text: { type: 'string', description: 'The message text' },
253
+ from: {
254
+ type: 'string',
255
+ description:
256
+ 'Who the message is from; defaults to the calling session id, or mcp outside a session. Ignored for a remote client, whose messages are always from its own name',
257
+ },
258
+ },
259
+ required: ['session', 'text'],
260
+ additionalProperties: false,
261
+ },
262
+ },
263
+ {
264
+ name: 'atc_message_get',
265
+ annotations: READ_ONLY,
266
+ scope: 'read',
267
+ description:
268
+ 'Read one message sent with atc_session_message: its id, session, from, text, status (accepted, delivered, or answered), the answer once answered, and the sentAt, deliveredAt, and answeredAt timestamps. Poll it until the status is answered.',
269
+ inputSchema: {
270
+ type: 'object',
271
+ properties: {
272
+ message: { type: 'string', description: 'The message id atc_session_message returned' },
273
+ },
274
+ required: ['message'],
275
+ additionalProperties: false,
276
+ },
277
+ },
278
+ ];
@@ -0,0 +1,13 @@
1
+ import { randomInt } from 'node:crypto';
2
+
3
+ // Crockford base32: no I, L, O, or U, so a code read off a terminal is never
4
+ // ambiguous.
5
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
6
+
7
+ /**
8
+ * A fresh 8-character approval code in Crockford base32, which the operator
9
+ * reads off the terminal and types into the approval page.
10
+ */
11
+ export function mintApprovalCode(): string {
12
+ return Array.from({ length: 8 }, () => ALPHABET[randomInt(ALPHABET.length)]).join('');
13
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Folds a typed approval code to the form it was minted in: upper case, with
3
+ * spaces and dashes dropped, and the letters Crockford base32 reads as digits
4
+ * (I and L as 1, O as 0) replaced.
5
+ */
6
+ export function normalizeApprovalCode(typed: string): string {
7
+ return typed.toUpperCase().replaceAll(/[\s-]/g, '').replaceAll(/[IL]/g, '1').replaceAll('O', '0');
8
+ }
@@ -0,0 +1,33 @@
1
+ import { isLoopbackHost } from './is-loopback-host';
2
+
3
+ /**
4
+ * Reduces a configured public URL to the bare origin the server is reached
5
+ * at: https, or http only on a loopback host, with no path, query, or
6
+ * fragment. The origin is the OAuth issuer, and `<origin>/mcp` is the
7
+ * resource every grant is bound to.
8
+ */
9
+ export function normalizePublicURL(raw: string): string {
10
+ let url: URL;
11
+
12
+ try {
13
+ url = new URL(raw);
14
+ } catch {
15
+ throw new Error(`the public URL '${raw}' is not a URL`);
16
+ }
17
+
18
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHost(url.hostname))) {
19
+ throw new Error(`the public URL '${raw}' must use https unless its host is loopback`);
20
+ }
21
+
22
+ if ((url.pathname !== '/' && url.pathname !== '') || url.search !== '' || url.hash !== '') {
23
+ throw new Error(
24
+ `the public URL '${raw}' must be a bare origin, with no path, query, or fragment`,
25
+ );
26
+ }
27
+
28
+ if (url.username !== '' || url.password !== '') {
29
+ throw new Error(`the public URL '${raw}' must not carry credentials`);
30
+ }
31
+
32
+ return url.origin;
33
+ }
@@ -0,0 +1,145 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { chmodSync, closeSync, openSync } from 'node:fs';
4
+ import { mcp } from '@better-auth/mcp';
5
+ import { oauthProvider } from '@better-auth/oauth-provider';
6
+ import type { OAuthOptions, Scope } from '@better-auth/oauth-provider';
7
+ import { betterAuth } from 'better-auth';
8
+ import type { BetterAuthPlugin } from 'better-auth';
9
+ import { getMigrations } from 'better-auth/db/migration';
10
+ import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
11
+ import { GRANT_SCOPES } from '../shared/grant-scope';
12
+ import { BunSqliteDriver } from '../store/bun-sqlite-driver';
13
+ import { buildOwnerPlugin } from './build-owner-plugin';
14
+ import { deriveTokenHash } from './derive-token-hash';
15
+ import type { MCPAuthSchema } from './types';
16
+
17
+ interface MCPAuthOptions {
18
+ readonly dbPath: string;
19
+
20
+ // The public origin: the issuer, with `<origin>/mcp` the one resource tokens
21
+ // are bound to. Null opens the store to manage clients and grants only, with
22
+ // no resource served.
23
+ readonly origin: string | null;
24
+
25
+ // How long a rotated refresh token still answers with its successor; 0
26
+ // treats any reuse as replay.
27
+ readonly refreshReuseSeconds?: number;
28
+ }
29
+
30
+ // How long an approval page, its signed query, and an authorization code each
31
+ // stay valid.
32
+ const APPROVAL_SECONDS = 600;
33
+
34
+ // The owner's session lives only to carry one approval from the code page
35
+ // through consent to the code exchange, which every token detaches from.
36
+ const OWNER_SESSION_SECONDS = 3 * APPROVAL_SECONDS;
37
+
38
+ /**
39
+ * Opens the authorization server's SQLite database and the better-auth
40
+ * instance over it, creating or updating its tables, with the file readable
41
+ * and writable by its owner only. better-auth runs the OAuth 2.1 flows: fixed
42
+ * public clients, PKCE, opaque access tokens, rotating refresh tokens, and
43
+ * revocation. Telemetry stays off whatever the environment says.
44
+ */
45
+ export async function openMCPAuth(options: MCPAuthOptions) {
46
+ // better-auth reads these when it builds its context; an empty endpoint
47
+ // turns its telemetry into a no-op.
48
+ process.env['BETTER_AUTH_TELEMETRY'] = '0';
49
+ process.env['BETTER_AUTH_TELEMETRY_ENDPOINT'] = '';
50
+
51
+ // The file holds token hashes and owner sessions, so only its owner may
52
+ // read it. SQLite gives the WAL and shared-memory files the main file's
53
+ // mode, so the mode is set before SQLite opens it.
54
+ closeSync(openSync(options.dbPath, 'a', 0o600));
55
+ chmodSync(options.dbPath, 0o600);
56
+
57
+ const sqlite = new Database(options.dbPath, { create: true });
58
+
59
+ sqlite.run('PRAGMA journal_mode = WAL;');
60
+ sqlite.run('PRAGMA busy_timeout = 5000;');
61
+
62
+ // The schema declares cascades from a client to its tokens and consent,
63
+ // which SQLite enforces only with foreign keys on.
64
+ sqlite.run('PRAGMA foreign_keys = ON;');
65
+
66
+ const db = new Kysely<MCPAuthSchema>({
67
+ dialect: {
68
+ createAdapter: () => new SqliteAdapter(),
69
+ createDriver: () => new BunSqliteDriver(sqlite),
70
+ createIntrospector: (kysely) => new SqliteIntrospector(kysely),
71
+ createQueryCompiler: () => new SqliteQueryCompiler(),
72
+ },
73
+ });
74
+
75
+ const providerOptions: OAuthOptions<Scope[]> = {
76
+ loginPage: '/login',
77
+ consentPage: '/consent',
78
+ scopes: [...GRANT_SCOPES, 'offline_access'],
79
+ disableJwtPlugin: true,
80
+ allowDynamicClientRegistration: false,
81
+ grantTypes: ['authorization_code', 'refresh_token'],
82
+
83
+ // atc serves one resource, so every client may request it.
84
+ enforcePerClientResources: false,
85
+ accessTokenExpiresIn: 3600,
86
+ codeExpiresIn: APPROVAL_SECONDS,
87
+ refreshTokenReuseInterval: options.refreshReuseSeconds ?? 0,
88
+ storeTokens: { hash: (token: string) => Promise.resolve(deriveTokenHash(token)) },
89
+ };
90
+
91
+ const provider =
92
+ options.origin === null
93
+ ? oauthProvider(providerOptions)
94
+ : mcp({ ...providerOptions, resource: `${options.origin}/mcp` });
95
+
96
+ const baseURL = options.origin ?? 'http://127.0.0.1';
97
+
98
+ const auth = betterAuth({
99
+ baseURL,
100
+ basePath: '/',
101
+ secret: randomBytes(32).toString('base64url'),
102
+ database: { db, type: 'sqlite' },
103
+ telemetry: { enabled: false },
104
+ logger: { level: 'error' },
105
+ rateLimit: { enabled: false },
106
+
107
+ // Every open runs the migrations itself, so the startup schema check
108
+ // would only report the tables a fresh database has yet to get.
109
+ advanced: { database: { validateSchema: false } },
110
+ trustedOrigins: [baseURL],
111
+ session: { expiresIn: OWNER_SESSION_SECONDS, disableSessionRefresh: true },
112
+
113
+ // The provider's own endpoint types do not satisfy the plugin type under
114
+ // exactOptionalPropertyTypes, so atc reaches them over HTTP-shaped requests.
115
+ // oxlint-disable-next-line no-unsafe-type-assertion -- the plugin is a BetterAuthPlugin at runtime; only its endpoint option types disagree with the stricter compiler settings
116
+ plugins: [provider as BetterAuthPlugin, buildOwnerPlugin()],
117
+ });
118
+
119
+ const migrations = await getMigrations(auth.options);
120
+
121
+ await migrations.runMigrations();
122
+
123
+ await sql`create table if not exists atc_grant_use (grant_id text primary key not null, last_used_at text not null)`.execute(
124
+ db,
125
+ );
126
+
127
+ // A resource served under an earlier public URL stays a valid token target
128
+ // until its row goes.
129
+ if (options.origin !== null) {
130
+ await db
131
+ .deleteFrom('oauthResource')
132
+ .where('identifier', '!=', `${options.origin}/mcp`)
133
+ .execute();
134
+ }
135
+
136
+ return {
137
+ auth,
138
+ db,
139
+ async close(): Promise<void> {
140
+ await db.destroy();
141
+
142
+ sqlite.close();
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,22 @@
1
+ // The error codes better-auth sends to the error page, each with the fixed
2
+ // sentence atc shows for it.
3
+ const ERROR_MESSAGES: Readonly<Record<string, string>> = {
4
+ invalid_client: 'the client is not one added to atc.',
5
+ client_disabled: 'the client is disabled.',
6
+ invalid_redirect: 'the redirect URI is not one the client was added with.',
7
+ unauthorized_client: 'the client may not use the authorization code grant.',
8
+ unsupported_response_type: 'atc supports only the authorization code response type.',
9
+ invalid_request: 'the request is malformed.',
10
+ };
11
+
12
+ /**
13
+ * The sentence the error page shows for an error code, or a generic one for
14
+ * a code atc does not know. The page never shows text from the request, so a
15
+ * link to it cannot make atc's origin display words an attacker chose.
16
+ */
17
+ export function pickErrorMessage(code: string | null): string {
18
+ const reason =
19
+ code !== null && Object.hasOwn(ERROR_MESSAGES, code) ? ERROR_MESSAGES[code] : undefined;
20
+
21
+ return `atc refused this authorization request: ${reason ?? 'the request is not valid.'}`;
22
+ }
@@ -0,0 +1,9 @@
1
+ import { isSupportedProtocolVersion } from './is-supported-protocol-version';
2
+
3
+ export function pickProtocolVersion(requested: unknown): string {
4
+ if (isSupportedProtocolVersion(requested)) {
5
+ return requested;
6
+ }
7
+
8
+ return '2025-11-25';
9
+ }
@@ -0,0 +1,121 @@
1
+ import { DaemonClient } from '../client/daemon-client';
2
+ import type { FleetCaller } from './types';
3
+
4
+ // The daemon requests the read-only tools send. Each only reads, so running
5
+ // one twice is harmless.
6
+ const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
7
+ 'dirs.list',
8
+ 'events.read',
9
+ 'message.get',
10
+ 'session.get',
11
+ 'session.list',
12
+ 'session.read',
13
+ 'session.resumeCommand',
14
+ 'session.screen',
15
+ ]);
16
+
17
+ /**
18
+ * A daemon caller that survives a daemon restart: once the connection ends,
19
+ * the next request opens and handshakes a fresh one. A request that was in
20
+ * flight when the connection ended is retried once on a fresh connection only
21
+ * when it is read-only; any other fails, because a spawn or a message must not
22
+ * run twice.
23
+ */
24
+ export class ReconnectingCaller implements FleetCaller {
25
+ private readonly socketPath: string;
26
+
27
+ private readonly build: string;
28
+
29
+ private client: Promise<DaemonClient> | null = null;
30
+
31
+ private readonly closed = new WeakSet<DaemonClient>();
32
+
33
+ constructor(socketPath: string, build: string) {
34
+ this.socketPath = socketPath;
35
+ this.build = build;
36
+ }
37
+
38
+ async sendRequest(
39
+ m: string,
40
+ p?: Readonly<Record<string, unknown>>,
41
+ ): Promise<Readonly<Record<string, unknown>>> {
42
+ const client = await this.openClient();
43
+
44
+ try {
45
+ return await client.sendRequest(m, p);
46
+ } catch (error) {
47
+ if (!this.closed.has(client) || !RETRYABLE_METHODS.has(m)) {
48
+ throw error;
49
+ }
50
+
51
+ const fresh = await this.openClient();
52
+
53
+ return fresh.sendRequest(m, p);
54
+ }
55
+ }
56
+
57
+ async stop(): Promise<void> {
58
+ const current = this.client;
59
+
60
+ this.client = null;
61
+
62
+ if (current === null) {
63
+ return;
64
+ }
65
+
66
+ try {
67
+ const client = await current;
68
+
69
+ client.stop();
70
+ } catch {
71
+ // A connection that never opened has nothing to close.
72
+ }
73
+ }
74
+
75
+ private openClient(): Promise<DaemonClient> {
76
+ if (this.client === null) {
77
+ const opening: Promise<DaemonClient> = this.openFreshClient(() => this.client === opening);
78
+
79
+ this.client = opening;
80
+ }
81
+
82
+ return this.client;
83
+ }
84
+
85
+ // isCurrent returns true while this connection is still the one later requests
86
+ // reuse: a connection that ends after a newer one replaced it must leave
87
+ // the newer one in place.
88
+ private async openFreshClient(isCurrent: () => boolean): Promise<DaemonClient> {
89
+ const resetClient = () => {
90
+ if (isCurrent()) {
91
+ this.client = null;
92
+ }
93
+ };
94
+
95
+ let client: DaemonClient;
96
+
97
+ try {
98
+ client = await DaemonClient.open(this.socketPath);
99
+ } catch (error) {
100
+ resetClient();
101
+ throw error;
102
+ }
103
+
104
+ client.onClose = () => {
105
+ this.closed.add(client);
106
+
107
+ resetClient();
108
+ };
109
+
110
+ try {
111
+ await client.sendHello(this.build);
112
+ } catch (error) {
113
+ client.stop();
114
+
115
+ resetClient();
116
+ throw error;
117
+ }
118
+
119
+ return client;
120
+ }
121
+ }
@@ -0,0 +1,36 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { MCPAuthSchema } from './types';
3
+
4
+ /**
5
+ * Removes a client and everything it holds: its access and refresh tokens,
6
+ * its consent, and when its grants were last used. Returns false for an
7
+ * unknown client id.
8
+ */
9
+ export function removeClient(db: Kysely<MCPAuthSchema>, clientID: string): Promise<boolean> {
10
+ return db.transaction().execute(async (trx) => {
11
+ const grants = await trx
12
+ .selectFrom('oauthRefreshToken')
13
+ .select('authorizationCodeId')
14
+ .where('clientId', '=', clientID)
15
+ .execute();
16
+
17
+ const grantIDs = grants.flatMap((grant) =>
18
+ grant.authorizationCodeId === null ? [] : [grant.authorizationCodeId],
19
+ );
20
+
21
+ if (grantIDs.length > 0) {
22
+ await trx.deleteFrom('atc_grant_use').where('grant_id', 'in', grantIDs).execute();
23
+ }
24
+
25
+ await trx.deleteFrom('oauthAccessToken').where('clientId', '=', clientID).execute();
26
+ await trx.deleteFrom('oauthRefreshToken').where('clientId', '=', clientID).execute();
27
+ await trx.deleteFrom('oauthConsent').where('clientId', '=', clientID).execute();
28
+
29
+ const removed = await trx
30
+ .deleteFrom('oauthClient')
31
+ .where('clientId', '=', clientID)
32
+ .executeTakeFirst();
33
+
34
+ return removed.numDeletedRows > 0n;
35
+ });
36
+ }