@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,18 @@
1
+ export type ParsedPort =
2
+ | { readonly ok: true; readonly port: number }
3
+ | { readonly ok: false; readonly message: string };
4
+
5
+ /**
6
+ * Parses a `--port` value: plain decimal digits naming a port from 1 to
7
+ * 65535. Signs, decimals, hex, exponents, and surrounding whitespace are all
8
+ * refused rather than coerced.
9
+ */
10
+ export function parsePort(raw: string): ParsedPort {
11
+ const port = /^\d{1,5}$/.test(raw) ? Number(raw) : Number.NaN;
12
+
13
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
14
+ return { ok: false, message: `--port takes a port from 1 to 65535, not '${raw}'` };
15
+ }
16
+
17
+ return { ok: true, port };
18
+ }
@@ -0,0 +1,46 @@
1
+ import { isRecord } from './report';
2
+
3
+ /**
4
+ * The settings `atc mcp --http` reads from config.json's `mcpHTTP` section.
5
+ */
6
+ export interface MCPHTTPConfig {
7
+ // The origin clients reach the server at, such as `https://mcp.example.com`.
8
+ readonly publicURL: string | null;
9
+
10
+ // The address the server binds.
11
+ readonly host: string;
12
+ readonly port: number;
13
+
14
+ // Further Host header values to accept, for a proxy that rewrites Host.
15
+ readonly allowedHosts: readonly string[];
16
+ }
17
+
18
+ /**
19
+ * Reads the `mcpHTTP` section. An absent or wrong-typed field falls back to
20
+ * its default: no public URL, host 127.0.0.1, port 8414, and no extra hosts.
21
+ */
22
+ export function collectMCPHTTPConfig(raw: unknown): MCPHTTPConfig {
23
+ if (!isRecord(raw)) {
24
+ return { publicURL: null, host: '127.0.0.1', port: 8414, allowedHosts: [] };
25
+ }
26
+
27
+ const publicURL = raw['publicURL'];
28
+ const host = raw['host'];
29
+ const port = raw['port'];
30
+
31
+ return {
32
+ publicURL: typeof publicURL === 'string' && publicURL !== '' ? publicURL : null,
33
+ host: typeof host === 'string' && host !== '' ? host : '127.0.0.1',
34
+ port:
35
+ typeof port === 'number' && Number.isInteger(port) && port >= 1 && port <= 65_535
36
+ ? port
37
+ : 8414,
38
+ allowedHosts: collectStrings(raw['allowedHosts']),
39
+ };
40
+ }
41
+
42
+ function collectStrings(raw: unknown): readonly string[] {
43
+ return Array.isArray(raw)
44
+ ? raw.filter((item): item is string => typeof item === 'string' && item !== '')
45
+ : [];
46
+ }
@@ -51,12 +51,14 @@ const DEFAULTS: Config = {
51
51
 
52
52
  const configDir = join(homedir(), '.config', 'atc');
53
53
 
54
+ export const configFile = join(configDir, 'config.json');
54
55
  export const stateDir = join(homedir(), '.local', 'state', 'atc');
55
56
  export const socketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc.sock');
56
57
  export const daemonSocketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.sock');
57
58
  export const eventsSocketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-events.sock');
58
59
  export const statusFile = join(stateDir, 'status.json');
59
60
  export const dbFile = join(stateDir, 'atc.db');
61
+ export const mcpAuthDBFile = join(stateDir, 'mcp-auth.db');
60
62
  export const legacyFleetFile = join(stateDir, 'fleet.json');
61
63
  export const daemonPidFile = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.pid');
62
64
 
@@ -80,7 +82,7 @@ export function loadConfig(): Config {
80
82
  mkdirSync(configDir, { recursive: true });
81
83
  mkdirSync(stateDir, { recursive: true });
82
84
 
83
- const file = join(configDir, 'config.json');
85
+ const file = configFile;
84
86
 
85
87
  if (!existsSync(file)) {
86
88
  writeFileSync(file, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The access a remote MCP client can be granted, one scope per kind of tool:
3
+ * reading the fleet, messaging a session, spawning or typing into one, and
4
+ * killing one.
5
+ */
6
+ export const GRANT_SCOPES = ['read', 'message', 'spawn', 'kill'] as const;
7
+
8
+ export type GrantScope = (typeof GRANT_SCOPES)[number];
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { collectMCPHTTPConfig } from './collect-mcp-http-config';
3
+ import type { MCPHTTPConfig } from './collect-mcp-http-config';
4
+ import { configFile } from './config';
5
+ import { isRecord } from './report';
6
+
7
+ /**
8
+ * Reads config.json's `mcpHTTP` section for `atc mcp --http`. Nothing else in
9
+ * atc reads it, and a missing or unreadable file means every default.
10
+ */
11
+ export function loadMCPHTTPConfig(): MCPHTTPConfig {
12
+ try {
13
+ const raw: unknown = JSON.parse(readFileSync(configFile, 'utf8'));
14
+ const section = isRecord(raw) ? raw['mcpHTTP'] : undefined;
15
+
16
+ return collectMCPHTTPConfig(section);
17
+ } catch {
18
+ return collectMCPHTTPConfig(undefined);
19
+ }
20
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Folds a client-supplied name to a single printable terminal line: control
3
+ * and format characters (escape sequences, line breaks, bidi overrides) are
4
+ * dropped, whitespace runs become one space, and the result is trimmed and
5
+ * cut to `maxLength` characters. A name with nothing left becomes the
6
+ * fallback. Any other client-supplied text bound for the terminal, such as a
7
+ * user agent, folds the same way.
8
+ */
9
+ export function normalizeClientName(
10
+ raw: unknown,
11
+ fallback = 'unnamed client',
12
+ maxLength = 100,
13
+ ): string {
14
+ if (typeof raw !== 'string') {
15
+ return fallback;
16
+ }
17
+
18
+ const folded = raw
19
+ .replaceAll(/[\t\n\v\f\r]/g, ' ')
20
+ .replaceAll(/[\p{Cc}\p{Cf}]/gu, '')
21
+ .replaceAll(/\s+/g, ' ')
22
+ .trim();
23
+
24
+ // The cut counts code points, so it never splits a surrogate pair.
25
+ const cut = folded.replace(new RegExp(`^(?<kept>.{${maxLength}}).+$`, 'su'), '$<kept>').trim();
26
+
27
+ return cut === '' ? fallback : cut;
28
+ }
@@ -18,6 +18,7 @@ import type { MessageOwner } from './message-owner';
18
18
  import type { MessageRecord } from './message-record';
19
19
  import { runMigrations } from './run-migrations';
20
20
  import type { StateStoreSchema } from './run-migrations';
21
+ import type { TrailEntry } from './trail-entry';
21
22
 
22
23
  // Spelled as the partial index's predicate so SQLite can match them.
23
24
  const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
@@ -33,11 +34,18 @@ export interface StoredEvent {
33
34
 
34
35
  // The event's detail, else the hook's message.
35
36
  readonly detail: string | null;
37
+
38
+ // The message id on a message status event.
39
+ readonly message?: MessageID;
40
+
41
+ // The report label on a report event.
42
+ readonly label?: string;
36
43
  }
37
44
 
38
45
  /**
39
- * Daemon state in one SQLite store: the restorable fleet, the hook-event
40
- * trail that events.read and lastActivityAt read, the spawn-directory
46
+ * Daemon state in one SQLite store: the restorable fleet, the event trail
47
+ * (hook events, message status changes, and reports) that events.read and
48
+ * lastActivityAt read, the spawn-directory
41
49
  * history, and the per-session message inbox. The statusline contract
42
50
  * file (status.json) stays a plain file because reporters inside wrangled
43
51
  * sessions read it without speaking to the daemon. An existing fleet.json
@@ -213,6 +221,23 @@ export class StateStore {
213
221
  .execute();
214
222
  }
215
223
 
224
+ async recordTrailEntry(entry: TrailEntry): Promise<void> {
225
+ await this.db
226
+ .insertInto('events')
227
+ .values({
228
+ ts: new Date(entry.at).toISOString(),
229
+ atc_id: entry.atcID,
230
+ event: entry.kind === 'report' ? 'SessionReport' : 'SessionMessage',
231
+
232
+ // The message column holds the message id or the report label; the reads hand it back by kind.
233
+ message: entry.kind === 'report' ? entry.label : entry.message,
234
+ session_id: entry.agentSessionID,
235
+ kind: entry.kind,
236
+ detail: entry.detail,
237
+ })
238
+ .execute();
239
+ }
240
+
216
241
  async collectEventsAfter(afterID: number, limit: number): Promise<StoredEvent[]> {
217
242
  const rows = await this.db
218
243
  .selectFrom('events')
@@ -298,7 +323,6 @@ export class StateStore {
298
323
  .execute();
299
324
  }
300
325
 
301
- // oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
302
326
  async writeMessage(record: MessageRecord): Promise<void> {
303
327
  await this.db
304
328
  .insertInto('messages')
@@ -407,6 +431,19 @@ export class StateStore {
407
431
  .execute();
408
432
  }
409
433
 
434
+ // Trail entries written before the agent reported its session id carry
435
+ // none; this stamps them once it is known, so they follow the session
436
+ // across a restore.
437
+ async updateTrailOwner(atcID: SessionID, next: AgentSessionID): Promise<void> {
438
+ await this.db
439
+ .updateTable('events')
440
+ .set({ session_id: next })
441
+ .where('atc_id', '=', atcID)
442
+ .where('session_id', 'is', null)
443
+ .where('kind', 'in', ['message-accepted', 'message-delivered', 'message-answered', 'report'])
444
+ .execute();
445
+ }
446
+
410
447
  async stop(): Promise<void> {
411
448
  await this.db.destroy();
412
449
 
@@ -469,13 +506,37 @@ function buildStoredEvents(rows: readonly EventRow[]): StoredEvent[] {
469
506
  atcID: toSessionID(row.atc_id),
470
507
  agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
471
508
  kind: row.kind,
472
- detail: row.detail ?? row.message,
509
+ ...buildTrailFields(row.kind, row.detail, row.message),
473
510
  });
474
511
  }
475
512
 
476
513
  return events;
477
514
  }
478
515
 
516
+ const MESSAGE_TRAIL_KINDS: ReadonlySet<string> = new Set([
517
+ 'message-accepted',
518
+ 'message-delivered',
519
+ 'message-answered',
520
+ ]);
521
+
522
+ // Message and report rows keep their message id or label where hook rows keep
523
+ // the hook's message, so only hook rows fall back to it for a detail.
524
+ function buildTrailFields(
525
+ kind: string,
526
+ detail: string | null,
527
+ message: string | null,
528
+ ): Pick<StoredEvent, 'detail' | 'message' | 'label'> {
529
+ if (MESSAGE_TRAIL_KINDS.has(kind)) {
530
+ return { detail, ...(message === null ? {} : { message: toMessageID(message) }) };
531
+ }
532
+
533
+ if (kind === 'report') {
534
+ return { detail, ...(message === null ? {} : { label: message }) };
535
+ }
536
+
537
+ return { detail: detail ?? message };
538
+ }
539
+
479
540
  function buildOwnerFilter(
480
541
  eb: ExpressionBuilder<StateStoreSchema, 'messages'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
481
542
  owner: MessageOwner,
@@ -0,0 +1,27 @@
1
+ import type { AgentSessionID } from '../shared/agent-session-id';
2
+ import type { MessageID } from '../shared/message-id';
3
+ import type { SessionID } from '../shared/session-id';
4
+
5
+ interface TrailEntryBase {
6
+ // Epoch ms the status change or report happened.
7
+ readonly at: number;
8
+ readonly atcID: SessionID;
9
+ readonly agentSessionID: AgentSessionID | null;
10
+ readonly detail: string;
11
+ }
12
+
13
+ interface MessageTrailEntry extends TrailEntryBase {
14
+ readonly kind: 'message-accepted' | 'message-delivered' | 'message-answered';
15
+ readonly message: MessageID;
16
+ }
17
+
18
+ interface ReportTrailEntry extends TrailEntryBase {
19
+ readonly kind: 'report';
20
+ readonly label: string;
21
+ }
22
+
23
+ /**
24
+ * One message status change or session report as the event trail holds it,
25
+ * beside the hook events.
26
+ */
27
+ export type TrailEntry = MessageTrailEntry | ReportTrailEntry;