@zgeoff/atc 2.8.1 → 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.
- package/README.md +17 -2
- package/package.json +6 -3
- package/src/agents/agent-adapter.ts +3 -1
- package/src/cli.ts +114 -4
- package/src/client/daemon-client.ts +10 -0
- package/src/clients.ts +82 -0
- package/src/collect-redirect-uris.ts +16 -0
- package/src/daemon/build-fleet-events.ts +9 -0
- package/src/daemon/build-message-trail-entry.ts +54 -0
- package/src/daemon/build-report-trail-entry.ts +25 -0
- package/src/daemon/build-session-message-event.ts +0 -1
- package/src/daemon/daemon.ts +72 -11
- package/src/daemon/sessions.ts +13 -1
- package/src/daemon/start-headless-run.ts +3 -8
- package/src/daemon/start-headless-turn.ts +15 -3
- package/src/daemon/truncate-summary.ts +9 -0
- package/src/grants.ts +51 -0
- package/src/mcp/answer-authorize-request.ts +98 -0
- package/src/mcp/answer-consent-request.ts +166 -0
- package/src/mcp/answer-login-request.ts +108 -0
- package/src/mcp/answer-mcp-request.ts +161 -0
- package/src/mcp/answer-rpc-request.ts +137 -0
- package/src/mcp/approval-state.ts +155 -0
- package/src/mcp/build-consent-binding.ts +13 -0
- package/src/mcp/build-owner-plugin.ts +122 -0
- package/src/mcp/build-page-response.ts +29 -0
- package/src/mcp/build-tool-list.ts +21 -0
- package/src/mcp/collect-clients.ts +31 -0
- package/src/mcp/collect-grants.ts +55 -0
- package/src/mcp/collect-json-strings.ts +17 -0
- package/src/mcp/derive-token-hash.ts +9 -0
- package/src/mcp/find-client-name.ts +19 -0
- package/src/mcp/find-owner-session-id.ts +18 -0
- package/src/mcp/is-allowed-redirect-uri.ts +21 -0
- package/src/mcp/is-loopback-host.ts +13 -0
- package/src/mcp/is-supported-protocol-version.ts +13 -0
- package/src/mcp/mcp-tools.ts +278 -0
- package/src/mcp/mint-approval-code.ts +13 -0
- package/src/mcp/normalize-approval-code.ts +8 -0
- package/src/mcp/normalize-public-url.ts +33 -0
- package/src/mcp/open-mcp-auth.ts +145 -0
- package/src/mcp/pick-error-message.ts +22 -0
- package/src/mcp/pick-protocol-version.ts +9 -0
- package/src/mcp/reconnecting-caller.ts +121 -0
- package/src/mcp/remove-client.ts +36 -0
- package/src/mcp/render-consent-page.ts +65 -0
- package/src/mcp/render-login-page.ts +47 -0
- package/src/mcp/revoke-grant.ts +28 -0
- package/src/mcp/run-tool.ts +143 -0
- package/src/mcp/start-mcp-http-server.ts +330 -0
- package/src/mcp/to-html-text.ts +12 -0
- package/src/mcp/types.ts +78 -0
- package/src/mcp/verify-oauth-query.ts +42 -0
- package/src/mcp-http-server.ts +75 -0
- package/src/mcp-server.ts +22 -450
- package/src/parse-port.ts +18 -0
- package/src/shared/collect-mcp-http-config.ts +46 -0
- package/src/shared/config.ts +3 -1
- package/src/shared/grant-scope.ts +8 -0
- package/src/shared/load-mcp-http-config.ts +20 -0
- package/src/shared/normalize-client-name.ts +28 -0
- package/src/store/state-store.ts +65 -4
- 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
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/store/state-store.ts
CHANGED
|
@@ -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
|
|
40
|
-
*
|
|
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
|
-
|
|
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;
|