@zgeoff/atc 2.10.4 → 2.11.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 +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +21 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/agents/claude-adapter.ts +4 -0
- package/src/agents/codex-adapter.ts +4 -0
- package/src/agents/gateway-adapter.ts +31 -0
- package/src/agents/grok-adapter.ts +4 -0
- package/src/cli.ts +4 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/daemon/build-agent-list.ts +58 -0
- package/src/daemon/daemon-connection.ts +44 -8
- package/src/daemon/daemon.ts +92 -22
- package/src/daemon/parse-report.ts +29 -3
- package/src/daemon/sessions.ts +5 -0
- package/src/mcp/answer-rpc-request.ts +13 -6
- package/src/mcp/build-tool-list.ts +60 -7
- package/src/mcp/mcp-tools.ts +151 -20
- package/src/mcp/reconnecting-caller.ts +42 -12
- package/src/mcp/require-daemon-features.ts +33 -0
- package/src/mcp/run-tool.ts +75 -23
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +7 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-features.ts +23 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/request-param-schemas.ts +18 -2
- package/src/report.ts +20 -3
- package/src/store/message-record.ts +3 -0
- package/src/store/run-migrations.ts +18 -2
- package/src/store/state-store.ts +77 -10
package/src/mcp/run-tool.ts
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import { match } from 'ts-pattern';
|
|
2
2
|
import { DaemonError } from '../protocol/daemon-error';
|
|
3
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
4
|
+
import { isRecord } from '../shared/report';
|
|
3
5
|
import type { FleetCaller, ToolContext } from './types';
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A tool's result: the text every client reads, and for a tool whose result
|
|
9
|
+
* is data, that data as an object for clients that read structured content.
|
|
10
|
+
*/
|
|
11
|
+
interface ToolResult {
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly structured: Readonly<Record<string, unknown>> | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
export function runTool(
|
|
6
17
|
caller: FleetCaller,
|
|
7
18
|
name: string,
|
|
8
19
|
args: Readonly<Record<string, unknown>>,
|
|
9
20
|
ctx: ToolContext,
|
|
10
|
-
): Promise<
|
|
21
|
+
): Promise<ToolResult> {
|
|
11
22
|
return match(name)
|
|
12
23
|
.with('atc_session_list', async () => {
|
|
13
24
|
const ok = await caller.sendRequest('session.list');
|
|
14
25
|
|
|
15
|
-
|
|
26
|
+
// The text stays the bare list older clients read; structured content
|
|
27
|
+
// has to be an object.
|
|
28
|
+
return {
|
|
29
|
+
text: JSON.stringify(ok['sessions'], null, 2),
|
|
30
|
+
structured: { sessions: ok['sessions'] },
|
|
31
|
+
};
|
|
16
32
|
})
|
|
17
33
|
.with('atc_session_spawn', async () => {
|
|
18
34
|
const rawAgent = args['agent'];
|
|
@@ -32,7 +48,7 @@ export function runTool(
|
|
|
32
48
|
? await sendNestedSpawn(caller, params, ctx.callerSessionID)
|
|
33
49
|
: await caller.sendRequest('session.spawn', params);
|
|
34
50
|
|
|
35
|
-
return
|
|
51
|
+
return buildObjectResult(ok['session']);
|
|
36
52
|
})
|
|
37
53
|
.with('atc_session_input', async () => {
|
|
38
54
|
await caller.sendRequest('session.input', {
|
|
@@ -40,12 +56,15 @@ export function runTool(
|
|
|
40
56
|
d: `${typeof args['text'] === 'string' ? args['text'] : ''}\n`,
|
|
41
57
|
});
|
|
42
58
|
|
|
43
|
-
return 'sent';
|
|
59
|
+
return { text: 'sent', structured: null };
|
|
44
60
|
})
|
|
45
61
|
.with('atc_session_screen', async () => {
|
|
46
62
|
const ok = await caller.sendRequest('session.screen', { session: args['session'] });
|
|
47
63
|
|
|
48
|
-
return
|
|
64
|
+
return {
|
|
65
|
+
text: typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok),
|
|
66
|
+
structured: null,
|
|
67
|
+
};
|
|
49
68
|
})
|
|
50
69
|
.with('atc_session_update', async () => {
|
|
51
70
|
await caller.sendRequest('session.update', {
|
|
@@ -54,32 +73,40 @@ export function runTool(
|
|
|
54
73
|
...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
|
|
55
74
|
});
|
|
56
75
|
|
|
57
|
-
return 'updated';
|
|
76
|
+
return { text: 'updated', structured: null };
|
|
58
77
|
})
|
|
59
78
|
.with('atc_session_kill', async () => {
|
|
60
79
|
await caller.sendRequest('session.kill', { session: args['session'] });
|
|
61
80
|
|
|
62
|
-
return 'killed';
|
|
81
|
+
return { text: 'killed', structured: null };
|
|
63
82
|
})
|
|
64
83
|
.with('atc_session_ack', async () => {
|
|
65
84
|
await caller.sendRequest('session.ack', { session: args['session'] });
|
|
66
85
|
|
|
67
|
-
return 'acked';
|
|
86
|
+
return { text: 'acked', structured: null };
|
|
68
87
|
})
|
|
69
88
|
.with('atc_resume_command', async () => {
|
|
70
89
|
const ok = await caller.sendRequest('session.resumeCommand', { session: args['session'] });
|
|
71
90
|
|
|
72
|
-
return
|
|
91
|
+
return {
|
|
92
|
+
text: typeof ok['command'] === 'string' ? ok['command'] : JSON.stringify(ok),
|
|
93
|
+
structured: null,
|
|
94
|
+
};
|
|
73
95
|
})
|
|
74
96
|
.with('atc_dirs_list', async () => {
|
|
75
97
|
const ok = await caller.sendRequest('dirs.list');
|
|
76
98
|
|
|
77
|
-
return JSON.stringify(ok['dirs'], null, 2);
|
|
99
|
+
return { text: JSON.stringify(ok['dirs'], null, 2), structured: { dirs: ok['dirs'] } };
|
|
100
|
+
})
|
|
101
|
+
.with('atc_agents_list', async () => {
|
|
102
|
+
const ok = await caller.sendRequest('agents.list', {}, ['agents.list']);
|
|
103
|
+
|
|
104
|
+
return buildObjectResult(ok);
|
|
78
105
|
})
|
|
79
106
|
.with('atc_session_get', async () => {
|
|
80
107
|
const ok = await caller.sendRequest('session.get', { session: args['session'] });
|
|
81
108
|
|
|
82
|
-
return
|
|
109
|
+
return buildObjectResult(ok);
|
|
83
110
|
})
|
|
84
111
|
.with('atc_session_read', async () => {
|
|
85
112
|
const ok = await caller.sendRequest('session.read', {
|
|
@@ -88,16 +115,24 @@ export function runTool(
|
|
|
88
115
|
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
89
116
|
});
|
|
90
117
|
|
|
91
|
-
return
|
|
118
|
+
return buildObjectResult(ok);
|
|
92
119
|
})
|
|
93
120
|
.with('atc_events_read', async () => {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
const filtered = typeof args['session'] === 'string' && args['session'] !== '';
|
|
122
|
+
const required: DaemonFeature[] = filtered ? ['events.session'] : [];
|
|
123
|
+
|
|
124
|
+
const ok = await caller.sendRequest(
|
|
125
|
+
'events.read',
|
|
126
|
+
{
|
|
127
|
+
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
128
|
+
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
129
|
+
...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
|
|
130
|
+
...(typeof args['session'] === 'string' ? { session: args['session'] } : {}),
|
|
131
|
+
},
|
|
132
|
+
required,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return buildObjectResult(ok);
|
|
101
136
|
})
|
|
102
137
|
.with('atc_session_message', async () => {
|
|
103
138
|
const given = args['from'];
|
|
@@ -113,16 +148,33 @@ export function runTool(
|
|
|
113
148
|
from,
|
|
114
149
|
});
|
|
115
150
|
|
|
116
|
-
return
|
|
151
|
+
return buildObjectResult(ok);
|
|
117
152
|
})
|
|
118
153
|
.with('atc_message_get', async () => {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
const waits = typeof args['waitMs'] === 'number' && args['waitMs'] > 0;
|
|
155
|
+
const required: DaemonFeature[] = waits ? ['message.wait'] : [];
|
|
156
|
+
|
|
157
|
+
const ok = await caller.sendRequest(
|
|
158
|
+
'message.get',
|
|
159
|
+
{
|
|
160
|
+
message: args['message'],
|
|
161
|
+
...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
|
|
162
|
+
},
|
|
163
|
+
required,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return buildObjectResult(ok);
|
|
122
167
|
})
|
|
123
168
|
.otherwise(() => Promise.reject(new Error(`unknown tool '${name}'`)));
|
|
124
169
|
}
|
|
125
170
|
|
|
171
|
+
function buildObjectResult(value: unknown): ToolResult {
|
|
172
|
+
return {
|
|
173
|
+
text: JSON.stringify(value, null, 2),
|
|
174
|
+
structured: isRecord(value) ? value : null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
126
178
|
// The inherited id can point at a session another daemon hosts, or one
|
|
127
179
|
// this daemon no longer lists; the spawn then lands top-level instead of
|
|
128
180
|
// failing the tool call.
|
|
@@ -30,6 +30,9 @@ interface MCPHTTPServerOptions {
|
|
|
30
30
|
readonly dbPath: string;
|
|
31
31
|
readonly printApproval: (line: string) => void;
|
|
32
32
|
|
|
33
|
+
// Receives one line per request the server answers.
|
|
34
|
+
readonly printRequest: (line: string) => void;
|
|
35
|
+
|
|
33
36
|
// How long a rotated refresh token still answers with its successor.
|
|
34
37
|
readonly refreshReuseSeconds?: number;
|
|
35
38
|
}
|
|
@@ -38,19 +41,24 @@ interface MCPHTTPServerOptions {
|
|
|
38
41
|
* A running MCP HTTP server.
|
|
39
42
|
*/
|
|
40
43
|
export interface MCPHTTPServer {
|
|
41
|
-
// The
|
|
44
|
+
// The loopback address the server answers on, for clients on this machine.
|
|
42
45
|
readonly url: string;
|
|
43
46
|
|
|
47
|
+
// The address the server is bound to.
|
|
48
|
+
readonly listening: string;
|
|
49
|
+
|
|
44
50
|
// The public origin: the OAuth issuer.
|
|
45
51
|
readonly origin: string;
|
|
46
52
|
readonly stop: () => Promise<void>;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/**
|
|
50
|
-
* Serves atc's MCP tools over streamable HTTP at `/mcp
|
|
51
|
-
* the OAuth 2.1 authorization server in the same process. Only
|
|
52
|
-
* connector and the operator's browser need reach better-auth;
|
|
53
|
-
* path is a 404.
|
|
56
|
+
* Serves atc's MCP tools over streamable HTTP at `/mcp` and `/`, with
|
|
57
|
+
* better-auth as the OAuth 2.1 authorization server in the same process. Only
|
|
58
|
+
* the routes a connector and the operator's browser need reach better-auth;
|
|
59
|
+
* every other path is a 404. Each answered request prints one line with its
|
|
60
|
+
* method, path, JSON-RPC method and tool, status, duration, and MCP protocol
|
|
61
|
+
* version, and never a body, query, credential, or address. A request whose Host header is not the server's own is
|
|
54
62
|
* refused, so a DNS rebinding page cannot reach it through a browser, and a
|
|
55
63
|
* browser form post from any other origin is refused too.
|
|
56
64
|
*/
|
|
@@ -81,23 +89,33 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
81
89
|
idleTimeout: 60,
|
|
82
90
|
maxRequestBodySize: MAX_LINE,
|
|
83
91
|
fetch: async (request, bunServer) => {
|
|
92
|
+
const startedAt = performance.now();
|
|
84
93
|
const state = holder.ready;
|
|
94
|
+
let rpc: RPCLabel | null = null;
|
|
95
|
+
let response: Response;
|
|
85
96
|
|
|
86
97
|
if (state === null) {
|
|
87
|
-
|
|
98
|
+
response = new Response(null, { status: 503 });
|
|
99
|
+
} else {
|
|
100
|
+
try {
|
|
101
|
+
response = await answerHTTPRequest(
|
|
102
|
+
state,
|
|
103
|
+
request,
|
|
104
|
+
bunServer.requestIP(request)?.address ?? null,
|
|
105
|
+
(label) => {
|
|
106
|
+
rpc = label;
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
response = new Response(null, { status: 503 });
|
|
111
|
+
}
|
|
88
112
|
}
|
|
89
113
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
request,
|
|
94
|
-
bunServer.requestIP(request)?.address ?? null,
|
|
95
|
-
);
|
|
114
|
+
options.printRequest(
|
|
115
|
+
formatRequestLine(request, rpc, response.status, performance.now() - startedAt),
|
|
116
|
+
);
|
|
96
117
|
|
|
97
|
-
|
|
98
|
-
} catch {
|
|
99
|
-
return new Response(null, { status: 503 });
|
|
100
|
-
}
|
|
118
|
+
return response;
|
|
101
119
|
},
|
|
102
120
|
});
|
|
103
121
|
|
|
@@ -141,6 +159,7 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
141
159
|
|
|
142
160
|
return {
|
|
143
161
|
url: local,
|
|
162
|
+
listening: formatBindURL(options.host, port),
|
|
144
163
|
origin,
|
|
145
164
|
stop: async () => {
|
|
146
165
|
await server.stop(true);
|
|
@@ -149,25 +168,101 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
149
168
|
};
|
|
150
169
|
}
|
|
151
170
|
|
|
171
|
+
// An IPv6 address takes the brackets a URL puts around it.
|
|
172
|
+
function formatBindURL(host: string, port: number): string {
|
|
173
|
+
const bracketed = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
174
|
+
|
|
175
|
+
return `http://${bracketed}:${port}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
152
178
|
interface ServerState {
|
|
153
179
|
readonly hosts: ReadonlySet<string>;
|
|
154
180
|
readonly origins: ReadonlySet<string>;
|
|
155
181
|
readonly ctx: HTTPServerContext;
|
|
156
182
|
}
|
|
157
183
|
|
|
184
|
+
// MCP answers at `/` as well as at `/mcp`, for a client configured with the
|
|
185
|
+
// bare origin. Both are the one resource `<origin>/mcp`.
|
|
186
|
+
const MCP_PATHS: ReadonlySet<string> = new Set(['/mcp', '/']);
|
|
187
|
+
|
|
188
|
+
// The JSON-RPC method and tool name of an MCP request, for its request line.
|
|
189
|
+
interface RPCLabel {
|
|
190
|
+
readonly method: string;
|
|
191
|
+
readonly tool: string | null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function findRPCLabel(body: string): RPCLabel | null {
|
|
195
|
+
let message: unknown;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
message = JSON.parse(body);
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!isRecord(message) || typeof message['method'] !== 'string') {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const params = message['params'];
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
method: message['method'],
|
|
211
|
+
tool: isRecord(params) && typeof params['name'] === 'string' ? params['name'] : null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Client-sent values in a request line are cut to this many characters.
|
|
216
|
+
const LABEL_LENGTH = 64;
|
|
217
|
+
|
|
218
|
+
// The line names the path without its query, which carries authorization
|
|
219
|
+
// codes and signed state.
|
|
220
|
+
function formatRequestLine(
|
|
221
|
+
request: Request,
|
|
222
|
+
rpc: RPCLabel | null,
|
|
223
|
+
status: number,
|
|
224
|
+
durationMs: number,
|
|
225
|
+
): string {
|
|
226
|
+
const path = new URL(request.url).pathname;
|
|
227
|
+
|
|
228
|
+
const version = request.headers.get('mcp-protocol-version');
|
|
229
|
+
|
|
230
|
+
const fields = [
|
|
231
|
+
request.method,
|
|
232
|
+
toLogText(path, 128),
|
|
233
|
+
String(status),
|
|
234
|
+
`${Math.round(durationMs)}ms`,
|
|
235
|
+
...(rpc === null ? [] : [`rpc=${toLogText(rpc.method, LABEL_LENGTH)}`]),
|
|
236
|
+
...(rpc === null || rpc.tool === null ? [] : [`tool=${toLogText(rpc.tool, LABEL_LENGTH)}`]),
|
|
237
|
+
...(version === null ? [] : [`mcp-protocol-version=${toLogText(version, LABEL_LENGTH)}`]),
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
return fields.join(' ');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Control and format characters are dropped, so a client cannot write
|
|
244
|
+
// escape sequences into the operator's terminal.
|
|
245
|
+
function toLogText(value: string, length: number): string {
|
|
246
|
+
return value.replaceAll(/[\p{Cc}\p{Cf}\s]/gu, '').slice(0, length);
|
|
247
|
+
}
|
|
248
|
+
|
|
158
249
|
// The better-auth routes a connector calls directly.
|
|
159
|
-
const PASSED_THROUGH: ReadonlySet<string> = new Set([
|
|
250
|
+
const PASSED_THROUGH: ReadonlySet<string> = new Set(['POST /oauth2/revoke']);
|
|
251
|
+
|
|
252
|
+
// Both paths serve the protected resource metadata for `<origin>/mcp`.
|
|
253
|
+
const RESOURCE_METADATA: ReadonlySet<string> = new Set([
|
|
160
254
|
'GET /.well-known/oauth-protected-resource',
|
|
161
255
|
'GET /.well-known/oauth-protected-resource/mcp',
|
|
162
|
-
'POST /oauth2/revoke',
|
|
163
256
|
]);
|
|
164
257
|
|
|
165
258
|
// `socketAddress` is the peer the request arrived from: the requester, or the
|
|
166
|
-
// proxy or tunnel in front of atc.
|
|
259
|
+
// proxy or tunnel in front of atc. `onRPC` receives an accepted MCP request's
|
|
260
|
+
// JSON-RPC method and tool, for its request line.
|
|
167
261
|
async function answerHTTPRequest(
|
|
168
262
|
state: ServerState,
|
|
169
263
|
request: Request,
|
|
170
264
|
socketAddress: string | null,
|
|
265
|
+
onRPC: (label: RPCLabel | null) => void,
|
|
171
266
|
): Promise<Response> {
|
|
172
267
|
const host = request.headers.get('host');
|
|
173
268
|
|
|
@@ -187,7 +282,7 @@ async function answerHTTPRequest(
|
|
|
187
282
|
return ctx.store.auth.handler(toPublicRequest(ctx, request, url));
|
|
188
283
|
}
|
|
189
284
|
|
|
190
|
-
if (route === 'GET /.well-known/oauth-authorization-server') {
|
|
285
|
+
if (route === 'GET /.well-known/oauth-authorization-server' || RESOURCE_METADATA.has(route)) {
|
|
191
286
|
return answerMetadataRequest(ctx, request, url);
|
|
192
287
|
}
|
|
193
288
|
|
|
@@ -226,7 +321,7 @@ async function answerHTTPRequest(
|
|
|
226
321
|
});
|
|
227
322
|
}
|
|
228
323
|
|
|
229
|
-
if (url.pathname
|
|
324
|
+
if (MCP_PATHS.has(url.pathname)) {
|
|
230
325
|
if (isForeignOrigin) {
|
|
231
326
|
return new Response(null, { status: 403 });
|
|
232
327
|
}
|
|
@@ -235,10 +330,14 @@ async function answerHTTPRequest(
|
|
|
235
330
|
return new Response(null, { status: 405, headers: { allow: 'POST' } });
|
|
236
331
|
}
|
|
237
332
|
|
|
333
|
+
const body = await request.text();
|
|
334
|
+
|
|
335
|
+
onRPC(findRPCLabel(body));
|
|
336
|
+
|
|
238
337
|
return answerMCPRequest(ctx, {
|
|
239
338
|
authorization: request.headers.get('authorization'),
|
|
240
339
|
protocolVersion: request.headers.get('mcp-protocol-version'),
|
|
241
|
-
body
|
|
340
|
+
body,
|
|
242
341
|
});
|
|
243
342
|
}
|
|
244
343
|
|
|
@@ -253,7 +352,8 @@ function toPublicRequest(ctx: HTTPServerContext, request: Request, url: URL): Re
|
|
|
253
352
|
}
|
|
254
353
|
|
|
255
354
|
// The metadata advertises only what atc's clients can use: public clients
|
|
256
|
-
// with no client authentication,
|
|
355
|
+
// with no client authentication, no introspection endpoint, and Bearer
|
|
356
|
+
// tokens alone, so nothing about DPoP.
|
|
257
357
|
async function answerMetadataRequest(
|
|
258
358
|
ctx: HTTPServerContext,
|
|
259
359
|
request: Request,
|
|
@@ -267,9 +367,15 @@ async function answerMetadataRequest(
|
|
|
267
367
|
}
|
|
268
368
|
|
|
269
369
|
const advertised = Object.fromEntries(
|
|
270
|
-
Object.entries(metadata).filter(
|
|
370
|
+
Object.entries(metadata).filter(
|
|
371
|
+
([key]) => !key.startsWith('introspection_') && !key.startsWith('dpop_'),
|
|
372
|
+
),
|
|
271
373
|
);
|
|
272
374
|
|
|
375
|
+
if (RESOURCE_METADATA.has(`${request.method} ${url.pathname}`)) {
|
|
376
|
+
return Response.json(advertised, { status: response.status });
|
|
377
|
+
}
|
|
378
|
+
|
|
273
379
|
return Response.json({
|
|
274
380
|
...advertised,
|
|
275
381
|
token_endpoint_auth_methods_supported: ['none'],
|
package/src/mcp/types.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
1
2
|
import type { ApprovalState } from './approval-state';
|
|
2
3
|
import type { openMCPAuth } from './open-mcp-auth';
|
|
3
4
|
|
|
4
|
-
// The slice of the daemon client the tool handlers need
|
|
5
|
+
// The slice of the daemon client the tool handlers need: requests, and the
|
|
6
|
+
// features the connected daemon announced at its handshake. A request that
|
|
7
|
+
// lists required features is checked against the connection it is about to
|
|
8
|
+
// ride, every time it is sent, and refused unsent when that daemon lacks one.
|
|
5
9
|
export interface FleetCaller {
|
|
6
10
|
readonly sendRequest: (
|
|
7
11
|
m: string,
|
|
8
12
|
p?: Readonly<Record<string, unknown>>,
|
|
13
|
+
required?: readonly DaemonFeature[],
|
|
9
14
|
) => Promise<Readonly<Record<string, unknown>>>;
|
|
15
|
+
readonly readFeatures: () => Promise<ReadonlySet<DaemonFeature>>;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
export interface ToolContext {
|
package/src/mcp-http-server.ts
CHANGED
|
@@ -43,9 +43,14 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
|
|
|
43
43
|
printApproval: (line) => {
|
|
44
44
|
console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
|
|
45
45
|
},
|
|
46
|
+
|
|
47
|
+
// Request lines go to stderr, so stdout keeps the approval lines alone.
|
|
48
|
+
printRequest: (line) => {
|
|
49
|
+
console.error(line);
|
|
50
|
+
},
|
|
46
51
|
});
|
|
47
52
|
|
|
48
|
-
console.log(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.
|
|
53
|
+
console.log(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.listening}`);
|
|
49
54
|
|
|
50
55
|
const admin = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
|
|
51
56
|
const clients = await collectClients(admin.db);
|
package/src/mcp-server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bootDaemonClient } from './client/boot-daemon';
|
|
2
2
|
import { answerRPCRequest } from './mcp/answer-rpc-request';
|
|
3
|
+
import { requireDaemonFeatures } from './mcp/require-daemon-features';
|
|
3
4
|
import type { FleetCaller, ToolContext } from './mcp/types';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -12,6 +13,17 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
12
13
|
|
|
13
14
|
const client = boot.client;
|
|
14
15
|
|
|
16
|
+
// The connection lives as long as the server, so the features its
|
|
17
|
+
// handshake announced hold for every call.
|
|
18
|
+
const caller: FleetCaller = {
|
|
19
|
+
sendRequest: (m, p, required = []) => {
|
|
20
|
+
requireDaemonFeatures(boot.features, required);
|
|
21
|
+
|
|
22
|
+
return client.sendRequest(m, p);
|
|
23
|
+
},
|
|
24
|
+
readFeatures: () => Promise.resolve(boot.features),
|
|
25
|
+
};
|
|
26
|
+
|
|
15
27
|
// The server inherits the calling session's id from its environment, so a
|
|
16
28
|
// spawn from inside a session nests under it by default.
|
|
17
29
|
const inherited = process.env['ATC_SESSION_ID'];
|
|
@@ -48,7 +60,7 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
48
60
|
|
|
49
61
|
void (async () => {
|
|
50
62
|
try {
|
|
51
|
-
await answerRPCLine(
|
|
63
|
+
await answerRPCLine(caller, build, toolContext, line);
|
|
52
64
|
} catch {
|
|
53
65
|
// A failed line gets no response, the way a malformed one gets none.
|
|
54
66
|
} finally {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request features a daemon announces in its `daemon.hello` answer. A
|
|
3
|
+
* daemon from before the list existed announces none, so a client that
|
|
4
|
+
* outlives an upgrade sees exactly what the running daemon serves.
|
|
5
|
+
*/
|
|
6
|
+
export const DAEMON_FEATURES = [
|
|
7
|
+
// `agents.list` exists.
|
|
8
|
+
'agents.list',
|
|
9
|
+
|
|
10
|
+
// `events.read` returns `more`.
|
|
11
|
+
'events.more',
|
|
12
|
+
|
|
13
|
+
// `events.read` takes a `session` filter.
|
|
14
|
+
'events.session',
|
|
15
|
+
|
|
16
|
+
// `message.get` returns `turn` and `answeredWith`.
|
|
17
|
+
'message.turn',
|
|
18
|
+
|
|
19
|
+
// `message.get` takes `waitMs`.
|
|
20
|
+
'message.wait',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DaemonFeature } from './daemon-features';
|
|
2
|
+
import { DAEMON_FEATURES } from './daemon-features';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads the features a `daemon.hello` answer announces. A missing or
|
|
6
|
+
* malformed list, which an older daemon sends, is no features, and a name
|
|
7
|
+
* this build does not know is dropped.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDaemonFeatures(
|
|
10
|
+
hello: Readonly<Record<string, unknown>>,
|
|
11
|
+
): ReadonlySet<DaemonFeature> {
|
|
12
|
+
const announced = hello['features'];
|
|
13
|
+
|
|
14
|
+
if (!Array.isArray(announced)) {
|
|
15
|
+
return new Set();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return new Set(DAEMON_FEATURES.filter((feature) => announced.includes(feature)));
|
|
19
|
+
}
|
|
@@ -24,6 +24,7 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
24
24
|
'daemon.quit': z.object({}),
|
|
25
25
|
'session.list': z.object({}),
|
|
26
26
|
'dirs.list': z.object({}),
|
|
27
|
+
'agents.list': z.object({}),
|
|
27
28
|
'fleet.list': z.object({}),
|
|
28
29
|
'fleet.restore': z.object({
|
|
29
30
|
cols: buildDefaultedNumber(80),
|
|
@@ -96,7 +97,13 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
96
97
|
'events.read': z.object({
|
|
97
98
|
cursor: buildOptionalCursor(),
|
|
98
99
|
limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
|
|
99
|
-
waitMs:
|
|
100
|
+
waitMs: buildDefaultedWait(),
|
|
101
|
+
|
|
102
|
+
// Limits the read to one session's events; absent or empty reads the whole fleet.
|
|
103
|
+
session: z.preprocess(
|
|
104
|
+
(v) => (typeof v === 'string' && v !== '' ? v : undefined),
|
|
105
|
+
z.string().transform(toSessionID).optional(),
|
|
106
|
+
),
|
|
100
107
|
}),
|
|
101
108
|
'session.message': SESSION_DEFAULTED.extend({
|
|
102
109
|
from: buildDefaultedNonEmptyString('unknown'),
|
|
@@ -104,7 +111,10 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
104
111
|
}).refine((v) => v.text !== '', { message: 'session.message requires text' }),
|
|
105
112
|
'session.tap': SESSION_DEFAULTED,
|
|
106
113
|
'message.get': z
|
|
107
|
-
.object({
|
|
114
|
+
.object({
|
|
115
|
+
message: buildDefaultedString('').transform(toMessageID),
|
|
116
|
+
waitMs: buildDefaultedWait(),
|
|
117
|
+
})
|
|
108
118
|
.refine((v) => v.message !== '', { message: 'message.get requires a message' }),
|
|
109
119
|
'message.ack': SESSION_DEFAULTED.extend({
|
|
110
120
|
message: buildDefaultedString('').transform(toMessageID),
|
|
@@ -119,6 +129,12 @@ function buildDefaultedNumber(fallback: number) {
|
|
|
119
129
|
return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().default(fallback));
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
// How long a read may hold its request open, in milliseconds: 0 when absent,
|
|
133
|
+
// clamped to 0–30000.
|
|
134
|
+
function buildDefaultedWait() {
|
|
135
|
+
return buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000));
|
|
136
|
+
}
|
|
137
|
+
|
|
122
138
|
function buildDefaultedBooleanOrString(fallback: boolean | string) {
|
|
123
139
|
return z.preprocess(
|
|
124
140
|
(v) => (typeof v === 'boolean' || typeof v === 'string' ? v : undefined),
|
package/src/report.ts
CHANGED
|
@@ -3,13 +3,21 @@ import { REPORT_KINDS } from './shared/report-kinds';
|
|
|
3
3
|
|
|
4
4
|
interface ReportOptions {
|
|
5
5
|
readonly message: string;
|
|
6
|
+
|
|
7
|
+
// Comma-separated ids of every message one turn answered, reported together.
|
|
8
|
+
readonly messages: string;
|
|
6
9
|
readonly label: string;
|
|
10
|
+
|
|
11
|
+
// The turn whose final reply an `answered` report carries; empty when unknown.
|
|
12
|
+
readonly turn: string;
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Runs inside wrangled sessions: reads stdin verbatim and forwards it to the
|
|
11
17
|
* atc socket as a Report envelope of the given kind. An `answered` report
|
|
12
|
-
* carries stdin as the final
|
|
18
|
+
* carries stdin as the final reply of the turn that carried the given
|
|
19
|
+
* message, or every given message at once, plus that turn's id when one is
|
|
20
|
+
* given; a `note` carries
|
|
13
21
|
* it as text for the user under the given label, `progress` when none is
|
|
14
22
|
* given. Always exits 0 so it never blocks the session it reports on.
|
|
15
23
|
*/
|
|
@@ -44,9 +52,18 @@ function buildReportPayload(
|
|
|
44
52
|
kind: string,
|
|
45
53
|
options: ReportOptions,
|
|
46
54
|
stdin: string,
|
|
47
|
-
): Record<string, string> | null {
|
|
55
|
+
): Record<string, string | readonly string[]> | null {
|
|
48
56
|
if (kind === 'answered') {
|
|
49
|
-
|
|
57
|
+
const messages = options.messages.split(',').filter((id) => id !== '');
|
|
58
|
+
const turn = options.turn === '' ? {} : { turn: options.turn };
|
|
59
|
+
|
|
60
|
+
if (messages.length > 0) {
|
|
61
|
+
return { kind, messages, answer: stdin, ...turn };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return options.message === ''
|
|
65
|
+
? null
|
|
66
|
+
: { kind, message: options.message, answer: stdin, ...turn };
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
if (kind === 'note') {
|