@zgeoff/atc 2.10.4 → 2.12.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 +72 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +52 -1
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +46 -1
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +75 -1
- package/src/agents/grok-adapter.ts +22 -0
- package/src/cli.ts +22 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +94 -0
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +371 -99
- package/src/daemon/daemon.ts +537 -90
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-report.ts +29 -3
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +287 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +40 -6
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +88 -7
- package/src/mcp/mcp-tools.ts +245 -24
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +77 -15
- package/src/mcp/require-daemon-features.ts +40 -0
- package/src/mcp/run-tool.ts +139 -35
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +9 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +47 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +78 -12
- package/src/report.ts +20 -3
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/message-record.ts +3 -0
- package/src/store/run-migrations.ts +272 -7
- package/src/store/state-store.ts +503 -34
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
package/src/mcp/run-tool.ts
CHANGED
|
@@ -1,38 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { match } from 'ts-pattern';
|
|
2
3
|
import { DaemonError } from '../protocol/daemon-error';
|
|
4
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
5
|
+
import { isRecord } from '../shared/report';
|
|
6
|
+
import { parseIdempotencyKey } from './parse-idempotency-key';
|
|
3
7
|
import type { FleetCaller, ToolContext } from './types';
|
|
4
8
|
|
|
9
|
+
/**
|
|
10
|
+
* A tool's result: the text every client reads, and for a tool whose result
|
|
11
|
+
* is data, that data as an object for clients that read structured content.
|
|
12
|
+
*/
|
|
13
|
+
interface ToolResult {
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly structured: Readonly<Record<string, unknown>> | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
export function runTool(
|
|
6
19
|
caller: FleetCaller,
|
|
7
20
|
name: string,
|
|
8
21
|
args: Readonly<Record<string, unknown>>,
|
|
9
22
|
ctx: ToolContext,
|
|
10
|
-
): Promise<
|
|
23
|
+
): Promise<ToolResult> {
|
|
11
24
|
return match(name)
|
|
12
25
|
.with('atc_session_list', async () => {
|
|
13
26
|
const ok = await caller.sendRequest('session.list');
|
|
14
27
|
|
|
15
|
-
|
|
28
|
+
// The text stays the bare list older clients read; structured content
|
|
29
|
+
// has to be an object.
|
|
30
|
+
return {
|
|
31
|
+
text: JSON.stringify(ok['sessions'], null, 2),
|
|
32
|
+
structured: { sessions: ok['sessions'] },
|
|
33
|
+
};
|
|
16
34
|
})
|
|
17
35
|
.with('atc_session_spawn', async () => {
|
|
18
36
|
const rawAgent = args['agent'];
|
|
19
37
|
const nested = args['detached'] !== true && ctx.callerSessionID !== null;
|
|
38
|
+
const key = parseIdempotencyKey(args['idempotencyKey']);
|
|
20
39
|
|
|
21
40
|
const params = {
|
|
22
41
|
cwd: args['cwd'],
|
|
23
42
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
24
43
|
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
25
44
|
...(rawAgent === undefined ? {} : { agent: rawAgent }),
|
|
45
|
+
...(args['model'] === undefined ? {} : { model: args['model'] }),
|
|
46
|
+
...(args['effort'] === undefined ? {} : { effort: args['effort'] }),
|
|
47
|
+
...(key === undefined ? {} : { idempotencyKey: key }),
|
|
48
|
+
...(args['target'] === undefined ? {} : { target: args['target'] }),
|
|
26
49
|
cols: 100,
|
|
27
50
|
rows: 30,
|
|
28
51
|
};
|
|
29
52
|
|
|
53
|
+
// A model, effort, key, or target needs a daemon that takes them; the check
|
|
54
|
+
// runs on every connection the spawn rides.
|
|
55
|
+
const optionFeatures: readonly DaemonFeature[] =
|
|
56
|
+
args['model'] === undefined && args['effort'] === undefined ? [] : ['spawn.options'];
|
|
57
|
+
|
|
58
|
+
const keyFeatures: readonly DaemonFeature[] = key === undefined ? [] : ['spawn.idempotency'];
|
|
59
|
+
|
|
60
|
+
const targetFeatures: readonly DaemonFeature[] =
|
|
61
|
+
args['target'] === undefined ? [] : ['spawn.target'];
|
|
62
|
+
|
|
63
|
+
const required = [...optionFeatures, ...keyFeatures, ...targetFeatures];
|
|
64
|
+
|
|
30
65
|
const ok =
|
|
31
66
|
nested && ctx.callerSessionID !== null
|
|
32
|
-
? await sendNestedSpawn(caller, params, ctx.callerSessionID)
|
|
33
|
-
: await caller.sendRequest('session.spawn', params);
|
|
67
|
+
? await sendNestedSpawn(caller, params, ctx.callerSessionID, required)
|
|
68
|
+
: await caller.sendRequest('session.spawn', params, required);
|
|
34
69
|
|
|
35
|
-
return
|
|
70
|
+
return buildObjectResult(ok['session']);
|
|
36
71
|
})
|
|
37
72
|
.with('atc_session_input', async () => {
|
|
38
73
|
await caller.sendRequest('session.input', {
|
|
@@ -40,12 +75,15 @@ export function runTool(
|
|
|
40
75
|
d: `${typeof args['text'] === 'string' ? args['text'] : ''}\n`,
|
|
41
76
|
});
|
|
42
77
|
|
|
43
|
-
return 'sent';
|
|
78
|
+
return { text: 'sent', structured: null };
|
|
44
79
|
})
|
|
45
80
|
.with('atc_session_screen', async () => {
|
|
46
81
|
const ok = await caller.sendRequest('session.screen', { session: args['session'] });
|
|
47
82
|
|
|
48
|
-
return
|
|
83
|
+
return {
|
|
84
|
+
text: typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok),
|
|
85
|
+
structured: null,
|
|
86
|
+
};
|
|
49
87
|
})
|
|
50
88
|
.with('atc_session_update', async () => {
|
|
51
89
|
await caller.sendRequest('session.update', {
|
|
@@ -54,32 +92,40 @@ export function runTool(
|
|
|
54
92
|
...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
|
|
55
93
|
});
|
|
56
94
|
|
|
57
|
-
return 'updated';
|
|
95
|
+
return { text: 'updated', structured: null };
|
|
58
96
|
})
|
|
59
97
|
.with('atc_session_kill', async () => {
|
|
60
98
|
await caller.sendRequest('session.kill', { session: args['session'] });
|
|
61
99
|
|
|
62
|
-
return 'killed';
|
|
100
|
+
return { text: 'killed', structured: null };
|
|
63
101
|
})
|
|
64
102
|
.with('atc_session_ack', async () => {
|
|
65
103
|
await caller.sendRequest('session.ack', { session: args['session'] });
|
|
66
104
|
|
|
67
|
-
return 'acked';
|
|
105
|
+
return { text: 'acked', structured: null };
|
|
68
106
|
})
|
|
69
107
|
.with('atc_resume_command', async () => {
|
|
70
108
|
const ok = await caller.sendRequest('session.resumeCommand', { session: args['session'] });
|
|
71
109
|
|
|
72
|
-
return
|
|
110
|
+
return {
|
|
111
|
+
text: typeof ok['command'] === 'string' ? ok['command'] : JSON.stringify(ok),
|
|
112
|
+
structured: null,
|
|
113
|
+
};
|
|
73
114
|
})
|
|
74
115
|
.with('atc_dirs_list', async () => {
|
|
75
116
|
const ok = await caller.sendRequest('dirs.list');
|
|
76
117
|
|
|
77
|
-
return JSON.stringify(ok['dirs'], null, 2);
|
|
118
|
+
return { text: JSON.stringify(ok['dirs'], null, 2), structured: { dirs: ok['dirs'] } };
|
|
119
|
+
})
|
|
120
|
+
.with('atc_agents_list', async () => {
|
|
121
|
+
const ok = await caller.sendRequest('agents.list', {}, ['agents.list']);
|
|
122
|
+
|
|
123
|
+
return buildObjectResult(ok);
|
|
78
124
|
})
|
|
79
125
|
.with('atc_session_get', async () => {
|
|
80
126
|
const ok = await caller.sendRequest('session.get', { session: args['session'] });
|
|
81
127
|
|
|
82
|
-
return
|
|
128
|
+
return buildObjectResult(ok);
|
|
83
129
|
})
|
|
84
130
|
.with('atc_session_read', async () => {
|
|
85
131
|
const ok = await caller.sendRequest('session.read', {
|
|
@@ -88,16 +134,24 @@ export function runTool(
|
|
|
88
134
|
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
89
135
|
});
|
|
90
136
|
|
|
91
|
-
return
|
|
137
|
+
return buildObjectResult(ok);
|
|
92
138
|
})
|
|
93
139
|
.with('atc_events_read', async () => {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
140
|
+
const filtered = typeof args['session'] === 'string' && args['session'] !== '';
|
|
141
|
+
const required: DaemonFeature[] = filtered ? ['events.session'] : [];
|
|
142
|
+
|
|
143
|
+
const ok = await caller.sendRequest(
|
|
144
|
+
'events.read',
|
|
145
|
+
{
|
|
146
|
+
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
147
|
+
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
148
|
+
...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
|
|
149
|
+
...(typeof args['session'] === 'string' ? { session: args['session'] } : {}),
|
|
150
|
+
},
|
|
151
|
+
required,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return buildObjectResult(ok);
|
|
101
155
|
})
|
|
102
156
|
.with('atc_session_message', async () => {
|
|
103
157
|
const given = args['from'];
|
|
@@ -107,37 +161,87 @@ export function runTool(
|
|
|
107
161
|
? given
|
|
108
162
|
: ctx.sender.name;
|
|
109
163
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
164
|
+
const key = parseIdempotencyKey(args['idempotencyKey']);
|
|
165
|
+
const required: DaemonFeature[] = key === undefined ? [] : ['message.idempotency'];
|
|
166
|
+
|
|
167
|
+
const ok = await caller.sendRequest(
|
|
168
|
+
'session.message',
|
|
169
|
+
{
|
|
170
|
+
session: args['session'],
|
|
171
|
+
text: args['text'],
|
|
172
|
+
from,
|
|
173
|
+
...(key === undefined ? {} : { idempotencyKey: key }),
|
|
174
|
+
},
|
|
175
|
+
required,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return buildObjectResult(ok);
|
|
117
179
|
})
|
|
118
180
|
.with('atc_message_get', async () => {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
181
|
+
const waits = typeof args['waitMs'] === 'number' && args['waitMs'] > 0;
|
|
182
|
+
const required: DaemonFeature[] = waits ? ['message.wait'] : [];
|
|
183
|
+
|
|
184
|
+
const ok = await caller.sendRequest(
|
|
185
|
+
'message.get',
|
|
186
|
+
{
|
|
187
|
+
message: args['message'],
|
|
188
|
+
...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
|
|
189
|
+
},
|
|
190
|
+
required,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return buildObjectResult(ok);
|
|
122
194
|
})
|
|
123
195
|
.otherwise(() => Promise.reject(new Error(`unknown tool '${name}'`)));
|
|
124
196
|
}
|
|
125
197
|
|
|
198
|
+
function buildObjectResult(value: unknown): ToolResult {
|
|
199
|
+
return {
|
|
200
|
+
text: JSON.stringify(value, null, 2),
|
|
201
|
+
structured: isRecord(value) ? value : null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
126
205
|
// The inherited id can point at a session another daemon hosts, or one
|
|
127
206
|
// this daemon no longer lists; the spawn then lands top-level instead of
|
|
128
|
-
// failing the tool call.
|
|
207
|
+
// failing the tool call. The top-level spawn has a different payload, so it
|
|
208
|
+
// runs under its own key, a fixed-length hash of the caller's: a retry of the
|
|
209
|
+
// tool call derives the same key and replays it rather than conflicting with
|
|
210
|
+
// the nested attempt's key, and the derived key fits the daemon's cap
|
|
211
|
+
// whatever the caller's length. An answer
|
|
212
|
+
// that holds an effect id is a keyed spawn that already ran, which a
|
|
213
|
+
// top-level spawn would only duplicate.
|
|
129
214
|
async function sendNestedSpawn(
|
|
130
215
|
caller: FleetCaller,
|
|
131
216
|
params: Readonly<Record<string, unknown>>,
|
|
132
217
|
parent: string,
|
|
218
|
+
required: readonly DaemonFeature[],
|
|
133
219
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
134
220
|
try {
|
|
135
|
-
return await caller.sendRequest('session.spawn', { ...params, parent });
|
|
221
|
+
return await caller.sendRequest('session.spawn', { ...params, parent }, required);
|
|
136
222
|
} catch (error) {
|
|
137
|
-
if (
|
|
138
|
-
|
|
223
|
+
if (
|
|
224
|
+
error instanceof DaemonError &&
|
|
225
|
+
error.code === 'no_such_session' &&
|
|
226
|
+
error.data?.['effectRef'] === undefined
|
|
227
|
+
) {
|
|
228
|
+
return caller.sendRequest('session.spawn', buildTopLevelParams(params), required);
|
|
139
229
|
}
|
|
140
230
|
|
|
141
231
|
throw error;
|
|
142
232
|
}
|
|
143
233
|
}
|
|
234
|
+
|
|
235
|
+
function buildTopLevelParams(
|
|
236
|
+
params: Readonly<Record<string, unknown>>,
|
|
237
|
+
): Readonly<Record<string, unknown>> {
|
|
238
|
+
const key = params['idempotencyKey'];
|
|
239
|
+
|
|
240
|
+
if (typeof key !== 'string') {
|
|
241
|
+
return params;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const digest = createHash('sha256').update(key).digest('hex');
|
|
245
|
+
|
|
246
|
+
return { ...params, idempotencyKey: `top-level:${digest}` };
|
|
247
|
+
}
|
|
@@ -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,20 @@
|
|
|
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.
|
|
9
|
+
// A request with a principal acts as that principal.
|
|
5
10
|
export interface FleetCaller {
|
|
6
11
|
readonly sendRequest: (
|
|
7
12
|
m: string,
|
|
8
13
|
p?: Readonly<Record<string, unknown>>,
|
|
14
|
+
required?: readonly DaemonFeature[],
|
|
15
|
+
principal?: string,
|
|
9
16
|
) => Promise<Readonly<Record<string, unknown>>>;
|
|
17
|
+
readonly readFeatures: () => Promise<ReadonlySet<DaemonFeature>>;
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
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 {
|
|
@@ -7,10 +7,14 @@ import type { ErrorCode } from './protocol';
|
|
|
7
7
|
export class DaemonError extends Error {
|
|
8
8
|
readonly code: ErrorCode;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Structured detail the error code defines for itself, sent as `err.data`.
|
|
11
|
+
readonly data: Readonly<Record<string, unknown>> | undefined;
|
|
12
|
+
|
|
13
|
+
constructor(code: ErrorCode, msg: string, data?: Readonly<Record<string, unknown>>) {
|
|
11
14
|
super(msg);
|
|
12
15
|
|
|
13
16
|
this.code = code;
|
|
17
|
+
this.data = data;
|
|
14
18
|
this.name = 'DaemonError';
|
|
15
19
|
}
|
|
16
20
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
|
|
22
|
+
// `session.spawn` takes `model` and `effort`, and `agents.list` returns
|
|
23
|
+
// `spawnOptions`.
|
|
24
|
+
'spawn.options',
|
|
25
|
+
|
|
26
|
+
// `daemon.hello` returns `daemonID`.
|
|
27
|
+
'daemon.id',
|
|
28
|
+
|
|
29
|
+
// Every session descriptor holds a `locator`.
|
|
30
|
+
'session.locator',
|
|
31
|
+
|
|
32
|
+
// `session.spawn` takes `idempotencyKey`.
|
|
33
|
+
'spawn.idempotency',
|
|
34
|
+
|
|
35
|
+
// `session.message` takes `idempotencyKey`.
|
|
36
|
+
'message.idempotency',
|
|
37
|
+
|
|
38
|
+
// `session.spawn` takes `target`, and `agents.list` returns `targets`,
|
|
39
|
+
// `spawnDefaults`, `configRevision`, and `targetErrors`.
|
|
40
|
+
'spawn.target',
|
|
41
|
+
|
|
42
|
+
// A request takes `as`, the principal it acts as, and `daemon.hello`
|
|
43
|
+
// takes `principal`, the principal the whole connection acts as.
|
|
44
|
+
'request.principal',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|