@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.
Files changed (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
@@ -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<string> {
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
- return JSON.stringify(ok['sessions'], null, 2);
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 JSON.stringify(ok['session'], null, 2);
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 typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok);
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 typeof ok['command'] === 'string' ? ok['command'] : JSON.stringify(ok);
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 JSON.stringify(ok, null, 2);
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 JSON.stringify(ok, null, 2);
137
+ return buildObjectResult(ok);
92
138
  })
93
139
  .with('atc_events_read', async () => {
94
- const ok = await caller.sendRequest('events.read', {
95
- ...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
96
- ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
97
- ...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
98
- });
99
-
100
- return JSON.stringify(ok, null, 2);
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 ok = await caller.sendRequest('session.message', {
111
- session: args['session'],
112
- text: args['text'],
113
- from,
114
- });
115
-
116
- return JSON.stringify(ok, null, 2);
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 ok = await caller.sendRequest('message.get', { message: args['message'] });
120
-
121
- return JSON.stringify(ok, null, 2);
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 (error instanceof DaemonError && error.code === 'no_such_session') {
138
- return caller.sendRequest('session.spawn', params);
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 local address the server listens on.
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`, with better-auth as
51
- * the OAuth 2.1 authorization server in the same process. Only the routes a
52
- * connector and the operator's browser need reach better-auth; every other
53
- * path is a 404. A request whose Host header is not the server's own is
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
- return new Response(null, { status: 503 });
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
- try {
91
- const answered = await answerHTTPRequest(
92
- state,
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
- return answered;
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 === '/mcp') {
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: await request.text(),
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, and no introspection endpoint.
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(([key]) => !key.startsWith('introspection_')),
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 {
@@ -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.url}`);
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(client, build, toolContext, line);
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
- constructor(code: ErrorCode, msg: string) {
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];