@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.
- package/README.md +5 -0
- package/package.json +6 -3
- package/src/agents/agent-adapter.ts +3 -1
- package/src/cli.ts +114 -4
- 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
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ApprovalState } from './approval-state';
|
|
2
|
+
import type { openMCPAuth } from './open-mcp-auth';
|
|
3
|
+
|
|
4
|
+
// The slice of the daemon client the tool handlers need.
|
|
5
|
+
export interface FleetCaller {
|
|
6
|
+
readonly sendRequest: (
|
|
7
|
+
m: string,
|
|
8
|
+
p?: Readonly<Record<string, unknown>>,
|
|
9
|
+
) => Promise<Readonly<Record<string, unknown>>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ToolContext {
|
|
13
|
+
readonly callerSessionID: string | null;
|
|
14
|
+
|
|
15
|
+
// Who a sent message is from. A `fixed` sender is always used; a `default`
|
|
16
|
+
// one gives way to a sender the tool call gives.
|
|
17
|
+
readonly sender: { readonly kind: 'fixed' | 'default'; readonly name: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The columns of the authorization server's database that atc reads and
|
|
22
|
+
* writes itself. better-auth owns the tables and their migrations; dates are
|
|
23
|
+
* ISO 8601 strings and string lists are JSON arrays.
|
|
24
|
+
*/
|
|
25
|
+
export interface MCPAuthSchema {
|
|
26
|
+
readonly oauthClient: {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly clientId: string;
|
|
29
|
+
readonly name: string | null;
|
|
30
|
+
readonly redirectUris: string;
|
|
31
|
+
readonly createdAt: string | null;
|
|
32
|
+
};
|
|
33
|
+
readonly oauthAccessToken: {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly token: string;
|
|
36
|
+
readonly clientId: string;
|
|
37
|
+
readonly sessionId: string | null;
|
|
38
|
+
readonly authorizationCodeId: string | null;
|
|
39
|
+
};
|
|
40
|
+
readonly oauthRefreshToken: {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly clientId: string;
|
|
43
|
+
readonly sessionId: string | null;
|
|
44
|
+
readonly authorizationCodeId: string | null;
|
|
45
|
+
readonly scopes: string;
|
|
46
|
+
readonly createdAt: string;
|
|
47
|
+
readonly expiresAt: string;
|
|
48
|
+
readonly revoked: string | null;
|
|
49
|
+
};
|
|
50
|
+
readonly oauthConsent: { readonly id: string; readonly clientId: string };
|
|
51
|
+
readonly oauthResource: { readonly identifier: string };
|
|
52
|
+
readonly session: { readonly id: string; readonly expiresAt: string };
|
|
53
|
+
|
|
54
|
+
// When each grant last reached /mcp, keyed by the grant's authorization code id.
|
|
55
|
+
readonly atc_grant_use: { readonly grant_id: string; readonly last_used_at: string };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The authorization server's store: the better-auth instance and the
|
|
60
|
+
* database under it.
|
|
61
|
+
*/
|
|
62
|
+
type MCPAuth = Awaited<ReturnType<typeof openMCPAuth>>;
|
|
63
|
+
|
|
64
|
+
// What every HTTP route reads: the daemon, the server's own identity, the
|
|
65
|
+
// authorization server, and the approvals waiting for the operator.
|
|
66
|
+
export interface HTTPServerContext {
|
|
67
|
+
readonly caller: FleetCaller;
|
|
68
|
+
readonly build: string;
|
|
69
|
+
|
|
70
|
+
// The public origin: the OAuth issuer.
|
|
71
|
+
readonly origin: string;
|
|
72
|
+
|
|
73
|
+
// `<origin>/mcp`: the one resource every token is bound to.
|
|
74
|
+
readonly resource: string;
|
|
75
|
+
readonly store: MCPAuth;
|
|
76
|
+
readonly approvals: ApprovalState;
|
|
77
|
+
readonly printApproval: (line: string) => void;
|
|
78
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { constantTimeEqual, makeSignature } from 'better-auth/crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a signed authorization query is one better-auth signed with this
|
|
5
|
+
* secret and has not expired: exactly one `sig`, matching the HMAC of every
|
|
6
|
+
* other parameter sorted by name and then value, and an `exp` still ahead.
|
|
7
|
+
*/
|
|
8
|
+
export async function verifyOAuthQuery(oauthQuery: string, secret: string): Promise<boolean> {
|
|
9
|
+
const params = new URLSearchParams(oauthQuery);
|
|
10
|
+
|
|
11
|
+
const signatures = params.getAll('sig');
|
|
12
|
+
const [signature] = signatures;
|
|
13
|
+
const expiresAt = Number(params.get('exp')) * 1000;
|
|
14
|
+
|
|
15
|
+
if (signatures.length !== 1 || signature === undefined || signature === '') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
params.delete('sig');
|
|
20
|
+
|
|
21
|
+
// Ordered by name, then by value, comparing code units as better-auth does
|
|
22
|
+
// when it signs; the NUL separator sorts a name before any longer name it
|
|
23
|
+
// prefixes.
|
|
24
|
+
const canonical = new URLSearchParams(
|
|
25
|
+
[...params].toSorted(([keyA, valueA], [keyB, valueB]) => {
|
|
26
|
+
const a = `${keyA}\u0000${valueA}`;
|
|
27
|
+
const b = `${keyB}\u0000${valueB}`;
|
|
28
|
+
|
|
29
|
+
if (a === b) {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return a < b ? -1 : 1;
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const expected = await makeSignature(canonical.toString(), secret);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
constantTimeEqual(signature, expected) && Number.isFinite(expiresAt) && expiresAt >= Date.now()
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { bootDaemonClient } from './client/boot-daemon';
|
|
3
|
+
import { collectClients } from './mcp/collect-clients';
|
|
4
|
+
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
5
|
+
import { ReconnectingCaller } from './mcp/reconnecting-caller';
|
|
6
|
+
import { startMCPHTTPServer } from './mcp/start-mcp-http-server';
|
|
7
|
+
import { daemonSocketPath, mcpAuthDBFile, stateDir } from './shared/config';
|
|
8
|
+
import { loadMCPHTTPConfig } from './shared/load-mcp-http-config';
|
|
9
|
+
|
|
10
|
+
interface MCPHTTPFlags {
|
|
11
|
+
readonly host: string | null;
|
|
12
|
+
readonly port: number | null;
|
|
13
|
+
readonly publicURL: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs `atc mcp --http` in the foreground: boots the daemon when it is down
|
|
18
|
+
* and serves MCP over HTTP until Ctrl-C. Approval codes print here, so the
|
|
19
|
+
* terminal running it is where the operator approves a client.
|
|
20
|
+
*/
|
|
21
|
+
export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Promise<void> {
|
|
22
|
+
const config = loadMCPHTTPConfig();
|
|
23
|
+
|
|
24
|
+
const boot = await bootDaemonClient();
|
|
25
|
+
|
|
26
|
+
boot.client.stop();
|
|
27
|
+
|
|
28
|
+
mkdirSync(stateDir, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const caller = new ReconnectingCaller(daemonSocketPath, build);
|
|
31
|
+
|
|
32
|
+
const server = await startMCPHTTPServer({
|
|
33
|
+
caller,
|
|
34
|
+
build,
|
|
35
|
+
host: flags.host ?? config.host,
|
|
36
|
+
port: flags.port ?? config.port,
|
|
37
|
+
publicURL: flags.publicURL ?? config.publicURL,
|
|
38
|
+
allowedHosts: config.allowedHosts,
|
|
39
|
+
dbPath: mcpAuthDBFile,
|
|
40
|
+
|
|
41
|
+
// The line carries a client's name, so control and format characters
|
|
42
|
+
// are dropped before it reaches the operator's terminal.
|
|
43
|
+
printApproval: (line) => {
|
|
44
|
+
console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
console.log(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.url}`);
|
|
49
|
+
|
|
50
|
+
const admin = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
|
|
51
|
+
const clients = await collectClients(admin.db);
|
|
52
|
+
|
|
53
|
+
await admin.close();
|
|
54
|
+
|
|
55
|
+
if (clients.length === 0) {
|
|
56
|
+
console.log(
|
|
57
|
+
'No clients can connect yet. Add one with: atc clients add <name> --redirect-uri <uri>',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const stopServing = async () => {
|
|
62
|
+
await server.stop();
|
|
63
|
+
await caller.stop();
|
|
64
|
+
|
|
65
|
+
process.exit(0);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
process.on('SIGINT', () => {
|
|
69
|
+
void stopServing();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
process.on('SIGTERM', () => {
|
|
73
|
+
void stopServing();
|
|
74
|
+
});
|
|
75
|
+
}
|
package/src/mcp-server.ts
CHANGED
|
@@ -1,225 +1,6 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
1
|
import { bootDaemonClient } from './client/boot-daemon';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { isRecord } from './shared/report';
|
|
6
|
-
|
|
7
|
-
// The slice of the daemon client the tool handlers need.
|
|
8
|
-
interface FleetCaller {
|
|
9
|
-
readonly sendRequest: (
|
|
10
|
-
m: string,
|
|
11
|
-
p?: Readonly<Record<string, unknown>>,
|
|
12
|
-
) => Promise<Readonly<Record<string, unknown>>>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const PROTOCOL_FALLBACK = '2025-06-18';
|
|
16
|
-
|
|
17
|
-
interface MCPTool {
|
|
18
|
-
readonly name: string;
|
|
19
|
-
readonly description: string;
|
|
20
|
-
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const NO_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(z.strictObject({}));
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* The session id shape MCP tool schemas require: present and described.
|
|
27
|
-
* The wire request schemas below share a defaulted-session shape instead,
|
|
28
|
-
* since they tolerate an absent session by defaulting it to an empty
|
|
29
|
-
* string.
|
|
30
|
-
*/
|
|
31
|
-
const SESSION_ID_BASE = z.object({
|
|
32
|
-
session: z.string().describe('The atc session id, from atc_session_list'),
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const SESSION_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(SESSION_ID_BASE.strict());
|
|
36
|
-
const SPAWN_SCHEMA = REQUEST_PARAM_SCHEMAS['session.spawn'];
|
|
37
|
-
|
|
38
|
-
const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
39
|
-
z.strictObject({
|
|
40
|
-
cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
|
|
41
|
-
name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
|
|
42
|
-
prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
|
|
43
|
-
agent: SPAWN_SCHEMA.shape.agent.describe(
|
|
44
|
-
'Which registered agent id to spawn; defaults to claude',
|
|
45
|
-
),
|
|
46
|
-
detached: z
|
|
47
|
-
.boolean()
|
|
48
|
-
.optional()
|
|
49
|
-
.describe(
|
|
50
|
-
'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.',
|
|
51
|
-
),
|
|
52
|
-
}),
|
|
53
|
-
{ io: 'input' },
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
57
|
-
SESSION_ID_BASE.extend({
|
|
58
|
-
cursor: z
|
|
59
|
-
.string()
|
|
60
|
-
.optional()
|
|
61
|
-
.describe(
|
|
62
|
-
'The cursor a previous atc_session_read returned; omit to read from the start of the conversation',
|
|
63
|
-
),
|
|
64
|
-
limit: z
|
|
65
|
-
.number()
|
|
66
|
-
.int()
|
|
67
|
-
.min(1)
|
|
68
|
-
.max(200)
|
|
69
|
-
.optional()
|
|
70
|
-
.describe('Most rows to return; defaults to 50'),
|
|
71
|
-
}).strict(),
|
|
72
|
-
{ io: 'input' },
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
76
|
-
z.strictObject({
|
|
77
|
-
cursor: z
|
|
78
|
-
.string()
|
|
79
|
-
.optional()
|
|
80
|
-
.describe(
|
|
81
|
-
'The cursor a previous atc_events_read returned; omit to get the most recent events',
|
|
82
|
-
),
|
|
83
|
-
limit: z
|
|
84
|
-
.number()
|
|
85
|
-
.int()
|
|
86
|
-
.min(1)
|
|
87
|
-
.max(200)
|
|
88
|
-
.optional()
|
|
89
|
-
.describe('Most events to return; defaults to 50'),
|
|
90
|
-
waitMs: z
|
|
91
|
-
.number()
|
|
92
|
-
.int()
|
|
93
|
-
.min(0)
|
|
94
|
-
.max(30_000)
|
|
95
|
-
.optional()
|
|
96
|
-
.describe(
|
|
97
|
-
'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
|
|
98
|
-
),
|
|
99
|
-
}),
|
|
100
|
-
{ io: 'input' },
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const TOOLS: readonly MCPTool[] = [
|
|
104
|
-
{
|
|
105
|
-
name: 'atc_session_list',
|
|
106
|
-
description:
|
|
107
|
-
'List every session the atc daemon hosts: id, name, working directory, state (running, needs_you, done, exited), unread flag, and last activity.',
|
|
108
|
-
inputSchema: NO_INPUT,
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
name: 'atc_session_spawn',
|
|
112
|
-
description:
|
|
113
|
-
'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.',
|
|
114
|
-
inputSchema: SPAWN_INPUT,
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
name: 'atc_session_input',
|
|
118
|
-
description:
|
|
119
|
-
'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.',
|
|
120
|
-
inputSchema: {
|
|
121
|
-
type: 'object',
|
|
122
|
-
properties: {
|
|
123
|
-
session: { type: 'string', description: 'The atc session id' },
|
|
124
|
-
text: { type: 'string', description: 'The line to type; a newline is appended' },
|
|
125
|
-
},
|
|
126
|
-
required: ['session', 'text'],
|
|
127
|
-
additionalProperties: false,
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
name: 'atc_session_screen',
|
|
132
|
-
description:
|
|
133
|
-
'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.',
|
|
134
|
-
inputSchema: SESSION_INPUT,
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
name: 'atc_session_update',
|
|
138
|
-
description:
|
|
139
|
-
'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.',
|
|
140
|
-
inputSchema: {
|
|
141
|
-
type: 'object',
|
|
142
|
-
properties: {
|
|
143
|
-
session: { type: 'string', description: 'The atc session id' },
|
|
144
|
-
name: { type: 'string', description: 'New display name; omit to keep' },
|
|
145
|
-
pinned: { type: 'boolean', description: 'Pin or unpin; omit to keep' },
|
|
146
|
-
},
|
|
147
|
-
required: ['session'],
|
|
148
|
-
additionalProperties: false,
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
name: 'atc_session_kill',
|
|
153
|
-
description: 'Kill a session. A second kill on a dead session removes it from the list.',
|
|
154
|
-
inputSchema: SESSION_INPUT,
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
name: 'atc_session_ack',
|
|
158
|
-
description: 'Clear a session unread flag without attaching to it.',
|
|
159
|
-
inputSchema: SESSION_INPUT,
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
name: 'atc_resume_command',
|
|
163
|
-
description:
|
|
164
|
-
'Build the shell command that reopens a session outside atc (cd into its directory and claude --resume its id).',
|
|
165
|
-
inputSchema: SESSION_INPUT,
|
|
166
|
-
},
|
|
167
|
-
{
|
|
168
|
-
name: 'atc_dirs_list',
|
|
169
|
-
description: 'List directories sessions were previously spawned from, most recent first.',
|
|
170
|
-
inputSchema: NO_INPUT,
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
name: 'atc_session_get',
|
|
174
|
-
description:
|
|
175
|
-
'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.',
|
|
176
|
-
inputSchema: SESSION_INPUT,
|
|
177
|
-
},
|
|
178
|
-
{
|
|
179
|
-
name: 'atc_session_read',
|
|
180
|
-
description:
|
|
181
|
-
"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.",
|
|
182
|
-
inputSchema: SESSION_READ_INPUT,
|
|
183
|
-
},
|
|
184
|
-
{
|
|
185
|
-
name: 'atc_events_read',
|
|
186
|
-
description:
|
|
187
|
-
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended) since a cursor, oldest first, each with the session id and name. Without a cursor it returns the most recent events. Pass the returned cursor next time. waitMs holds the call open until an event arrives.',
|
|
188
|
-
inputSchema: EVENTS_READ_INPUT,
|
|
189
|
-
},
|
|
190
|
-
{
|
|
191
|
-
name: 'atc_session_message',
|
|
192
|
-
description:
|
|
193
|
-
"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.",
|
|
194
|
-
inputSchema: {
|
|
195
|
-
type: 'object',
|
|
196
|
-
properties: {
|
|
197
|
-
session: { type: 'string', description: 'The atc session id, from atc_session_list' },
|
|
198
|
-
text: { type: 'string', description: 'The message text' },
|
|
199
|
-
from: {
|
|
200
|
-
type: 'string',
|
|
201
|
-
description:
|
|
202
|
-
'Who the message is from; defaults to the calling session id, or mcp outside a session',
|
|
203
|
-
},
|
|
204
|
-
},
|
|
205
|
-
required: ['session', 'text'],
|
|
206
|
-
additionalProperties: false,
|
|
207
|
-
},
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
name: 'atc_message_get',
|
|
211
|
-
description:
|
|
212
|
-
'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.',
|
|
213
|
-
inputSchema: {
|
|
214
|
-
type: 'object',
|
|
215
|
-
properties: {
|
|
216
|
-
message: { type: 'string', description: 'The message id atc_session_message returned' },
|
|
217
|
-
},
|
|
218
|
-
required: ['message'],
|
|
219
|
-
additionalProperties: false,
|
|
220
|
-
},
|
|
221
|
-
},
|
|
222
|
-
];
|
|
2
|
+
import { answerRPCRequest } from './mcp/answer-rpc-request';
|
|
3
|
+
import type { FleetCaller, ToolContext } from './mcp/types';
|
|
223
4
|
|
|
224
5
|
/**
|
|
225
6
|
* An MCP server over stdio bridging to the atc daemon: any MCP client —
|
|
@@ -231,6 +12,16 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
231
12
|
|
|
232
13
|
const client = boot.client;
|
|
233
14
|
|
|
15
|
+
// The server inherits the calling session's id from its environment, so a
|
|
16
|
+
// spawn from inside a session nests under it by default.
|
|
17
|
+
const inherited = process.env['ATC_SESSION_ID'];
|
|
18
|
+
const callerSessionID = inherited === undefined || inherited === '' ? null : inherited;
|
|
19
|
+
|
|
20
|
+
const toolContext: ToolContext = {
|
|
21
|
+
callerSessionID,
|
|
22
|
+
sender: { kind: 'default', name: callerSessionID ?? 'mcp' },
|
|
23
|
+
};
|
|
24
|
+
|
|
234
25
|
const decoder = new TextDecoder('utf-8');
|
|
235
26
|
|
|
236
27
|
let buffer = '';
|
|
@@ -257,7 +48,7 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
257
48
|
|
|
258
49
|
void (async () => {
|
|
259
50
|
try {
|
|
260
|
-
await
|
|
51
|
+
await answerRPCLine(client, build, toolContext, line);
|
|
261
52
|
} catch {
|
|
262
53
|
// A failed line gets no response, the way a malformed one gets none.
|
|
263
54
|
} finally {
|
|
@@ -273,7 +64,12 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
273
64
|
client.stop();
|
|
274
65
|
}
|
|
275
66
|
|
|
276
|
-
async function
|
|
67
|
+
async function answerRPCLine(
|
|
68
|
+
caller: FleetCaller,
|
|
69
|
+
build: string,
|
|
70
|
+
toolContext: ToolContext,
|
|
71
|
+
line: string,
|
|
72
|
+
): Promise<void> {
|
|
277
73
|
let parsed: unknown;
|
|
278
74
|
|
|
279
75
|
try {
|
|
@@ -282,233 +78,9 @@ async function applyRPCLine(client: FleetCaller, build: string, line: string): P
|
|
|
282
78
|
return;
|
|
283
79
|
}
|
|
284
80
|
|
|
285
|
-
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const method = parsed['method'];
|
|
290
|
-
const id = parsed['id'];
|
|
291
|
-
const params = isRecord(parsed['params']) ? parsed['params'] : {};
|
|
292
|
-
|
|
293
|
-
// Notifications carry no id and get no response.
|
|
294
|
-
if (id === undefined || id === null) {
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (typeof id !== 'string' && typeof id !== 'number') {
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
switch (method) {
|
|
303
|
-
case 'initialize': {
|
|
304
|
-
const requested = params['protocolVersion'];
|
|
305
|
-
|
|
306
|
-
sendResult(id, {
|
|
307
|
-
protocolVersion: typeof requested === 'string' ? requested : PROTOCOL_FALLBACK,
|
|
308
|
-
capabilities: { tools: {} },
|
|
309
|
-
serverInfo: { name: 'atc', version: build },
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
case 'ping': {
|
|
315
|
-
sendResult(id, {});
|
|
316
|
-
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
case 'tools/list': {
|
|
320
|
-
sendResult(id, { tools: TOOLS });
|
|
321
|
-
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
case 'tools/call': {
|
|
325
|
-
await applyToolCall(client, id, params);
|
|
326
|
-
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
default: {
|
|
330
|
-
sendError(id, -32_601, `unknown method '${method}'`);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
async function applyToolCall(
|
|
336
|
-
client: FleetCaller,
|
|
337
|
-
id: string | number,
|
|
338
|
-
params: Readonly<Record<string, unknown>>,
|
|
339
|
-
): Promise<void> {
|
|
340
|
-
const name = typeof params['name'] === 'string' ? params['name'] : '';
|
|
341
|
-
const args = isRecord(params['arguments']) ? params['arguments'] : {};
|
|
342
|
-
|
|
343
|
-
try {
|
|
344
|
-
const text = await runTool(client, name, args);
|
|
345
|
-
|
|
346
|
-
sendResult(id, { content: [{ type: 'text', text }] });
|
|
347
|
-
} catch (error) {
|
|
348
|
-
let msg = String(error);
|
|
349
|
-
|
|
350
|
-
if (error instanceof DaemonError) {
|
|
351
|
-
msg = `${error.code}: ${error.message}`;
|
|
352
|
-
} else if (error instanceof Error) {
|
|
353
|
-
msg = error.message;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
sendResult(id, { content: [{ type: 'text', text: msg }], isError: true });
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function runTool(
|
|
361
|
-
client: FleetCaller,
|
|
362
|
-
name: string,
|
|
363
|
-
args: Readonly<Record<string, unknown>>,
|
|
364
|
-
): Promise<string> {
|
|
365
|
-
switch (name) {
|
|
366
|
-
case 'atc_session_list': {
|
|
367
|
-
const ok = await client.sendRequest('session.list');
|
|
368
|
-
|
|
369
|
-
return JSON.stringify(ok['sessions'], null, 2);
|
|
370
|
-
}
|
|
371
|
-
case 'atc_session_spawn': {
|
|
372
|
-
const rawAgent = args['agent'];
|
|
373
|
-
|
|
374
|
-
// The server inherits the calling session's id from its environment,
|
|
375
|
-
// so a spawn from inside a session nests under it by default.
|
|
376
|
-
const caller = process.env['ATC_SESSION_ID'];
|
|
377
|
-
const nested = args['detached'] !== true && caller !== undefined && caller !== '';
|
|
378
|
-
|
|
379
|
-
const params = {
|
|
380
|
-
cwd: args['cwd'],
|
|
381
|
-
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
382
|
-
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
383
|
-
...(rawAgent === undefined ? {} : { agent: rawAgent }),
|
|
384
|
-
cols: 100,
|
|
385
|
-
rows: 30,
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
const ok = nested
|
|
389
|
-
? await sendNestedSpawn(client, params, caller)
|
|
390
|
-
: await client.sendRequest('session.spawn', params);
|
|
391
|
-
|
|
392
|
-
return JSON.stringify(ok['session'], null, 2);
|
|
393
|
-
}
|
|
394
|
-
case 'atc_session_input': {
|
|
395
|
-
await client.sendRequest('session.input', {
|
|
396
|
-
session: args['session'],
|
|
397
|
-
d: `${typeof args['text'] === 'string' ? args['text'] : ''}\n`,
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
return 'sent';
|
|
401
|
-
}
|
|
402
|
-
case 'atc_session_screen': {
|
|
403
|
-
const ok = await client.sendRequest('session.screen', { session: args['session'] });
|
|
404
|
-
|
|
405
|
-
return typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok);
|
|
406
|
-
}
|
|
407
|
-
case 'atc_session_update': {
|
|
408
|
-
await client.sendRequest('session.update', {
|
|
409
|
-
session: args['session'],
|
|
410
|
-
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
411
|
-
...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
return 'updated';
|
|
415
|
-
}
|
|
416
|
-
case 'atc_session_kill': {
|
|
417
|
-
await client.sendRequest('session.kill', { session: args['session'] });
|
|
418
|
-
|
|
419
|
-
return 'killed';
|
|
420
|
-
}
|
|
421
|
-
case 'atc_session_ack': {
|
|
422
|
-
await client.sendRequest('session.ack', { session: args['session'] });
|
|
423
|
-
|
|
424
|
-
return 'acked';
|
|
425
|
-
}
|
|
426
|
-
case 'atc_resume_command': {
|
|
427
|
-
const ok = await client.sendRequest('session.resumeCommand', { session: args['session'] });
|
|
428
|
-
|
|
429
|
-
return typeof ok['command'] === 'string' ? ok['command'] : JSON.stringify(ok);
|
|
430
|
-
}
|
|
431
|
-
case 'atc_dirs_list': {
|
|
432
|
-
const ok = await client.sendRequest('dirs.list');
|
|
433
|
-
|
|
434
|
-
return JSON.stringify(ok['dirs'], null, 2);
|
|
435
|
-
}
|
|
436
|
-
case 'atc_session_get': {
|
|
437
|
-
const ok = await client.sendRequest('session.get', { session: args['session'] });
|
|
438
|
-
|
|
439
|
-
return JSON.stringify(ok, null, 2);
|
|
440
|
-
}
|
|
441
|
-
case 'atc_session_read': {
|
|
442
|
-
const ok = await client.sendRequest('session.read', {
|
|
443
|
-
session: args['session'],
|
|
444
|
-
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
445
|
-
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
return JSON.stringify(ok, null, 2);
|
|
449
|
-
}
|
|
450
|
-
case 'atc_events_read': {
|
|
451
|
-
const ok = await client.sendRequest('events.read', {
|
|
452
|
-
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
453
|
-
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
454
|
-
...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
return JSON.stringify(ok, null, 2);
|
|
458
|
-
}
|
|
459
|
-
case 'atc_session_message': {
|
|
460
|
-
const caller = process.env['ATC_SESSION_ID'];
|
|
461
|
-
const given = args['from'];
|
|
462
|
-
let from = 'mcp';
|
|
463
|
-
|
|
464
|
-
if (typeof given === 'string' && given !== '') {
|
|
465
|
-
from = given;
|
|
466
|
-
} else if (caller !== undefined && caller !== '') {
|
|
467
|
-
from = caller;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const ok = await client.sendRequest('session.message', {
|
|
471
|
-
session: args['session'],
|
|
472
|
-
text: args['text'],
|
|
473
|
-
from,
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
return JSON.stringify(ok, null, 2);
|
|
477
|
-
}
|
|
478
|
-
case 'atc_message_get': {
|
|
479
|
-
const ok = await client.sendRequest('message.get', { message: args['message'] });
|
|
480
|
-
|
|
481
|
-
return JSON.stringify(ok, null, 2);
|
|
482
|
-
}
|
|
483
|
-
default: {
|
|
484
|
-
throw new Error(`unknown tool '${name}'`);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function sendResult(id: string | number, result: Readonly<Record<string, unknown>>): void {
|
|
490
|
-
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function sendError(id: string | number, code: number, message: string): void {
|
|
494
|
-
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// The inherited id can point at a session another daemon hosts, or one
|
|
498
|
-
// this daemon no longer lists; the spawn then lands top-level instead of
|
|
499
|
-
// failing the tool call.
|
|
500
|
-
async function sendNestedSpawn(
|
|
501
|
-
client: FleetCaller,
|
|
502
|
-
params: Readonly<Record<string, unknown>>,
|
|
503
|
-
parent: string,
|
|
504
|
-
): Promise<Readonly<Record<string, unknown>>> {
|
|
505
|
-
try {
|
|
506
|
-
return await client.sendRequest('session.spawn', { ...params, parent });
|
|
507
|
-
} catch (error) {
|
|
508
|
-
if (error instanceof DaemonError && error.code === 'no_such_session') {
|
|
509
|
-
return client.sendRequest('session.spawn', params);
|
|
510
|
-
}
|
|
81
|
+
const outcome = await answerRPCRequest(parsed, { caller, build, toolContext });
|
|
511
82
|
|
|
512
|
-
|
|
83
|
+
if (outcome.kind === 'reply') {
|
|
84
|
+
process.stdout.write(`${JSON.stringify(outcome.body)}\n`);
|
|
513
85
|
}
|
|
514
86
|
}
|