@lanes-sh/link 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A self-hosted MCP gateway for your accounts, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/lanes-sh/link#readme",
@@ -1,6 +1,6 @@
1
1
  import { listProfiles } from '#profile';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { deployedUrl, endpointUrl } from '../../endpoint-url.ts';
3
+ import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
4
4
  import { announce, heading, print, style, warn } from '../../output.ts';
5
5
  import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
6
6
 
@@ -9,11 +9,6 @@ export interface OutputsFlags extends GlobalFlags {
9
9
  readonly json?: boolean | undefined;
10
10
  }
11
11
 
12
- interface Health {
13
- readonly profile: string;
14
- readonly profiles: readonly string[];
15
- }
16
-
17
12
  /**
18
13
  * What an agent harness needs to reach this endpoint.
19
14
  *
@@ -34,9 +29,11 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
34
29
  const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
35
30
  const declared = runtime.config.targets[runtime.target]?.deploy;
36
31
  const deployed = await deployedUrl(declared);
37
- const url = deployed ?? (await endpointUrl(runtime.config, runtime.target));
32
+ // Not `endpointUrl`, which would ask the platform a second time for an
33
+ // answer this line already has.
34
+ const url = deployed ?? localUrl(runtime.config);
38
35
 
39
- const live = await health(url, token);
36
+ const live = await endpointHealth(url, token);
40
37
  const mine = live?.profile === runtime.resolution.profile;
41
38
 
42
39
  // Live if it is up, otherwise what `start` would serve: the whole
@@ -174,33 +171,3 @@ async function tokenInvocation(
174
171
  return { command: `bun run ${entry} link token show --raw${suffix}`, onPath: false };
175
172
  }
176
173
 
177
- /**
178
- * Ask the endpoint who it is.
179
- *
180
- * The profile name is checked by the caller, not just the port: two workspaces
181
- * can assign the same port, and reporting "running" because something
182
- * unrelated answers would send someone to register an endpoint serving another
183
- * workspace's accounts.
184
- *
185
- * The token is sent because `/health` names profiles only to a caller that
186
- * holds one. Anonymously it answers `{status: "ok"}` and nothing else — that
187
- * list is what this endpoint holds, and a deployed URL is readable by anyone.
188
- * An endpoint that answers without naming itself is therefore reported as
189
- * something else's, which is the honest reading: this token does not open it.
190
- */
191
- async function health(url: string, token: string): Promise<Health | null> {
192
- try {
193
- const probe = new URL(url);
194
- probe.pathname = '/health';
195
- const response = await fetch(probe, {
196
- headers: { authorization: `Bearer ${token}` },
197
- signal: AbortSignal.timeout(700),
198
- });
199
- if (!response.ok) return null;
200
-
201
- const body = (await response.json()) as Partial<Health>;
202
- return body.profile ? { profile: body.profile, profiles: body.profiles ?? [body.profile] } : null;
203
- } catch {
204
- return null;
205
- }
206
- }
@@ -0,0 +1,343 @@
1
+ import { capabilityIdForToolName } from '#server/mcp';
2
+ import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
3
+ import { announce, emit, heading, print, style, warn } from '../../output.ts';
4
+ import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
5
+
6
+ export interface ToolsFlags extends GlobalFlags {
7
+ readonly json?: boolean | undefined;
8
+ }
9
+
10
+ /**
11
+ * What a client is actually being told, asked over the wire.
12
+ *
13
+ * Not derived from the config, and that is the whole reason this exists.
14
+ * `doctor` answers whether the credentials resolve and `outputs` answers where
15
+ * the endpoint is; neither answers the question that follows a client showing
16
+ * the wrong tools, which is what the endpoint would hand it right now. Working
17
+ * that out previously meant hand-rolling a `tools/list` with `curl` and a token
18
+ * pulled out of the secret store, and reading a byte count out of a request log
19
+ * to guess at the answer.
20
+ *
21
+ * The `listChanged` line is here for the same reason. A client refreshes by
22
+ * asking again, and a server that claims it will announce changes gives it a
23
+ * reason not to — so a surface that looks stale in a client and current here is
24
+ * explained by that flag more often than by anything else (ADR-032).
25
+ */
26
+ export async function tools(flags: ToolsFlags): Promise<void> {
27
+ const runtime = await openRuntime(flags);
28
+
29
+ try {
30
+ const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
31
+ const declared = runtime.config.targets[runtime.target]?.deploy;
32
+ const deployed = await deployedUrl(declared);
33
+ // Not `endpointUrl`, which asks the platform a second time for an answer
34
+ // this line already has.
35
+ const url = deployed ?? localUrl(runtime.config);
36
+
37
+ // Before trusting anything the surface says: two workspaces can assign the
38
+ // same port, and `secrets push` makes their tokens match, so a `--target
39
+ // cloud` whose deployment could not be located answers from loopback with a
40
+ // token that works. Reporting that as the deployed endpoint's surface is
41
+ // the failure `endpoint-url.ts` calls "silent in the worst way".
42
+ const live = await endpointHealth(url, token);
43
+ const mine = live?.profile === runtime.resolution.profile;
44
+
45
+ const surface = await askEndpoint(url, token);
46
+ const providers = [...new Set(runtime.registry.capabilities().map(({ id }) => id))];
47
+
48
+ await emit(
49
+ flags.json,
50
+ {
51
+ url,
52
+ target: runtime.target,
53
+ // Both, because `--target cloud` reaching loopback is indistinguishable
54
+ // from success without them.
55
+ deployed: deployed !== null,
56
+ answering: mine,
57
+ ...surfaceJson(surface),
58
+ },
59
+ () => {
60
+ announce(runtime.resolution);
61
+
62
+ heading('Endpoint');
63
+ print(` ${url} ${reachability(mine, deployed !== null, surface)}`);
64
+
65
+ if (declared && !deployed) {
66
+ // The case the ownership probe cannot catch on its own: the address
67
+ // is loopback because the platform could not be asked, not because
68
+ // this target is local.
69
+ print(
70
+ warn(
71
+ `could not ask ${declared.platform} where "${declared.service}" is — this is the local endpoint, not the deployed one`,
72
+ ),
73
+ );
74
+ } else if (live && !mine) {
75
+ print(warn(`something else is serving this port: profile "${live.profile}"`));
76
+ }
77
+
78
+ if (!surface.reachable) {
79
+ print(fail(surface));
80
+ return;
81
+ }
82
+
83
+ heading(`Advertised to a client (${surface.names.length})`);
84
+ for (const [provider, names] of groupByProvider(surface.names, providers)) {
85
+ print(` ${style.bold(provider)} ${style.dim(`${names.length}`)}`);
86
+ for (const name of names) print(` ${name}`);
87
+ }
88
+
89
+ heading('How a client sees it');
90
+ print(` payload: ${kb(surface.bytes)} for the whole list`);
91
+ print(` listChanged: ${listChangedLine(surface.listChanged)}`);
92
+ print(
93
+ style.dim(
94
+ ' Tools only — a skill is a prompt, and is not counted here or by `connect`.',
95
+ ),
96
+ );
97
+ },
98
+ );
99
+
100
+ // Non-zero for the same reason `doctor` does it: a command whose whole job
101
+ // is to answer a question exits failing when it could not answer.
102
+ if (!surface.reachable) process.exitCode = 1;
103
+ } finally {
104
+ await runtime.close();
105
+ }
106
+ }
107
+
108
+ /**
109
+ * What is at this address, in one word.
110
+ *
111
+ * The refusal case is why this is not just `mine`. `/health` is asked with the
112
+ * same token, so an endpoint belonging to another workspace fails that probe
113
+ * too — and reporting "not running" above a refusal that begins "it is
114
+ * answering" is the command contradicting itself in two consecutive lines.
115
+ */
116
+ function reachability(mine: boolean, deployed: boolean, surface: Surface): string {
117
+ if (mine) return style.green(deployed ? 'deployed' : 'running');
118
+ if (surface.refused) return style.yellow('answering, but not for this token');
119
+ return style.dim(deployed ? 'not answering' : 'not running');
120
+ }
121
+
122
+ /**
123
+ * Why the surface could not be read, said as the thing that happened.
124
+ *
125
+ * Refused and unreachable are different problems with different fixes, and
126
+ * folding them together sends someone to check whether the endpoint is up when
127
+ * it answered them perfectly well and declined their token.
128
+ */
129
+ function fail(surface: Surface): string {
130
+ if (surface.refused) {
131
+ return (
132
+ warn(`the endpoint refused this token: ${surface.reason}`) +
133
+ `\n${style.dim(' It is answering. Either the token was rotated without re-registering, or\n this address belongs to another workspace.')}`
134
+ );
135
+ }
136
+
137
+ return (
138
+ warn(`could not ask it: ${surface.reason}`) +
139
+ `\n${style.dim(' Nothing here reads the config to guess instead — an endpoint that cannot\n be asked is exactly the case where a guess would be believed.')}`
140
+ );
141
+ }
142
+
143
+ interface Surface {
144
+ readonly reachable: boolean;
145
+ readonly reason?: string;
146
+ /** It answered, and declined. Distinct from not answering at all. */
147
+ readonly refused?: boolean;
148
+ readonly names: readonly string[];
149
+ readonly bytes: number;
150
+ readonly listChanged?: boolean | undefined;
151
+ }
152
+
153
+ /**
154
+ * `tools` as a count, matching `/reload` and `connect`; the names beside it.
155
+ *
156
+ * One key, one meaning. `ReloadResult.tools` and `PublishOutcome.tools` are both
157
+ * numbers, and the count is what `docs/connect.md` tells an operator to compare
158
+ * against their client — shipping the same key here as an array would make
159
+ * `.tools > 5` true for a single tool.
160
+ */
161
+ function surfaceJson(surface: Surface): Record<string, unknown> {
162
+ return {
163
+ reachable: surface.reachable,
164
+ ...(surface.reason !== undefined ? { reason: surface.reason } : {}),
165
+ ...(surface.refused !== undefined ? { refused: surface.refused } : {}),
166
+ tools: surface.names.length,
167
+ names: surface.names,
168
+ bytes: surface.bytes,
169
+ ...(surface.listChanged !== undefined ? { listChanged: surface.listChanged } : {}),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * One `initialize`, one `tools/list`, exactly as a 2025-era client sends them.
175
+ *
176
+ * Deliberately hand-rolled rather than run through an MCP client library: the
177
+ * subject is what the endpoint puts on the wire, and a library that negotiated
178
+ * a newer revision would answer a different question than the one asked.
179
+ */
180
+ export async function askEndpoint(url: string, token: string): Promise<Surface> {
181
+ const post = async (body: unknown): Promise<{ text: string; result: Record<string, unknown> }> => {
182
+ const response = await fetch(url, {
183
+ method: 'POST',
184
+ headers: {
185
+ 'content-type': 'application/json',
186
+ // Both, and not one: the streamable HTTP transport answers 406 to a
187
+ // client that will not accept an event stream, whatever it then sends.
188
+ accept: 'application/json, text/event-stream',
189
+ authorization: `Bearer ${token}`,
190
+ },
191
+ body: JSON.stringify(body),
192
+ signal: AbortSignal.timeout(30_000),
193
+ });
194
+
195
+ const text = await response.text();
196
+ if (!response.ok) throw new Refusal(response.status, text);
197
+
198
+ return { text, result: parse(text) };
199
+ };
200
+
201
+ try {
202
+ const init = await post({
203
+ jsonrpc: '2.0',
204
+ id: 1,
205
+ method: 'initialize',
206
+ params: {
207
+ protocolVersion: '2025-06-18',
208
+ capabilities: {},
209
+ clientInfo: { name: 'lanes-link-cli', version: '0.0.0' },
210
+ },
211
+ });
212
+
213
+ const capabilities = init.result['capabilities'] as
214
+ | { tools?: { listChanged?: boolean } }
215
+ | undefined;
216
+
217
+ const listed = await post({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
218
+ const names = ((listed.result['tools'] as Array<{ name?: string }> | undefined) ?? [])
219
+ .map((tool) => tool.name ?? '')
220
+ .filter((name) => name.length > 0)
221
+ .sort();
222
+
223
+ return {
224
+ reachable: true,
225
+ names,
226
+ bytes: Buffer.byteLength(listed.text),
227
+ listChanged: capabilities?.tools?.listChanged,
228
+ };
229
+ } catch (error) {
230
+ if (error instanceof Refusal) {
231
+ return { reachable: false, refused: error.refused, reason: error.message, names: [], bytes: 0 };
232
+ }
233
+
234
+ return {
235
+ reachable: false,
236
+ reason: error instanceof Error ? error.message : String(error),
237
+ names: [],
238
+ bytes: 0,
239
+ };
240
+ }
241
+ }
242
+
243
+ /** An endpoint that answered and would not serve this call. */
244
+ class Refusal extends Error {
245
+ readonly refused: boolean;
246
+
247
+ constructor(status: number, body: string) {
248
+ // The transport puts the actionable reason in a JSON-RPC error body for
249
+ // 400/406/415, and discarding it leaves only a number to act on.
250
+ const detail = errorMessage(body);
251
+ super(detail ? `${status} — ${detail}` : `answered ${status}`);
252
+ this.refused = status === 401 || status === 403;
253
+ }
254
+ }
255
+
256
+ function errorMessage(body: string): string | null {
257
+ try {
258
+ const parsed = JSON.parse(body) as { error?: { message?: string } | string };
259
+ if (typeof parsed.error === 'string') return parsed.error;
260
+ return parsed.error?.message ?? null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * The JSON payload of a response that may or may not be framed as SSE.
268
+ *
269
+ * Three shapes reach here and all three are legal. A plain JSON body; an event
270
+ * stream whose payload line is `data: {…}`; and one where the space after the
271
+ * colon is absent, which the spec permits. The stream may also open with
272
+ * comment lines — the transport arms a keep-alive on every POST, so a handler
273
+ * that runs long enough emits `: keepalive` before anything else — so the
274
+ * payload is found by scanning rather than by inspecting the first characters.
275
+ */
276
+ export function parse(text: string): Record<string, unknown> {
277
+ const data = text.split('\n').find((line) => line.startsWith('data:'));
278
+ const body = data === undefined ? text : data.slice('data:'.length).trim();
279
+
280
+ const message = JSON.parse(body) as {
281
+ result?: Record<string, unknown>;
282
+ error?: { message?: string };
283
+ };
284
+
285
+ if (message.error) throw new Error(message.error.message ?? 'the endpoint returned an error');
286
+ return message.result ?? {};
287
+ }
288
+
289
+ /**
290
+ * Group by provider, resolving each wire name back to the capability it is.
291
+ *
292
+ * A wire name is the capability id with its dots replaced (`naming.ts`), so
293
+ * `icloud_mail.send_message` arrives as `icloud_mail_send_message` and there is
294
+ * nothing left in the string to say where the provider ends. Splitting on the
295
+ * first underscore would file it under `icloud`, which is not a provider.
296
+ *
297
+ * `capabilityIdForToolName` answers it exactly against a set of known ids, and
298
+ * falls back to that first-underscore split when it recognises none — which is
299
+ * reachable here, because the registry is the *invoking profile's* while the
300
+ * endpoint may serve several, and under `--target cloud` may run an image this
301
+ * checkout does not have. So the fallback is detected rather than trusted: a
302
+ * name that resolves to nothing known is grouped as unattributed, because a
303
+ * guessed heading with a confident count is worse than an honest "these did not
304
+ * match anything I know about".
305
+ */
306
+ export function groupByProvider(
307
+ names: readonly string[],
308
+ capabilityIds: readonly string[],
309
+ ): Map<string, string[]> {
310
+ const known = new Set(capabilityIds);
311
+ const grouped = new Map<string, string[]>();
312
+
313
+ for (const name of names) {
314
+ const id = capabilityIdForToolName(name, capabilityIds);
315
+ const provider = known.has(id) ? id.slice(0, id.indexOf('.')) : UNATTRIBUTED;
316
+
317
+ const bucket = grouped.get(provider);
318
+ if (bucket) bucket.push(name);
319
+ else grouped.set(provider, [name]);
320
+ }
321
+
322
+ return new Map([...grouped].sort(([a], [b]) => a.localeCompare(b)));
323
+ }
324
+
325
+ /** Named rather than spelled inline, so the output and the test agree. */
326
+ export const UNATTRIBUTED = '(not in this profile)';
327
+
328
+ function kb(bytes: number): string {
329
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
330
+ }
331
+
332
+ function listChangedLine(declared: boolean | undefined): string {
333
+ if (declared === undefined) return style.dim('not declared');
334
+ if (declared === false) {
335
+ return `false ${style.dim('— a client re-reads this list rather than waiting to be told')}`;
336
+ }
337
+
338
+ // Worth a warning rather than a value, because it is the shape of a bug that
339
+ // presents as "the endpoint is wrong" when the endpoint is right: a client
340
+ // that trusts the promise keeps the list it first fetched, for as long as it
341
+ // is registered.
342
+ return style.yellow('true') + style.dim(' — but nothing here sends the notification');
343
+ }
@@ -20,6 +20,7 @@
20
20
  export { check, doctor, plan } from './operate/inspect.ts';
21
21
  export { status } from './operate/status.ts';
22
22
  export { outputs, type OutputsFlags } from './operate/outputs.ts';
23
+ export { tools, type ToolsFlags } from './operate/tools.ts';
23
24
  export { start } from './operate/serve.ts';
24
25
  export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
25
26
  export { attachFile } from './operate/attach.ts';
@@ -17,7 +17,52 @@ import type { Config, DeployConfig } from '#profile';
17
17
  */
18
18
  export async function endpointUrl(config: Config, target: string): Promise<string> {
19
19
  const deployed = await deployedUrl(config.targets[target]?.deploy);
20
- return deployed ?? `http://${config.instance.host}:${config.instance.port}/mcp`;
20
+ return deployed ?? localUrl(config);
21
+ }
22
+
23
+ /**
24
+ * Where `lanes link start` would listen, from config alone.
25
+ *
26
+ * Split out so a caller that has already asked `deployedUrl` can name the
27
+ * fallback without asking again — `endpointUrl` is the two together, and going
28
+ * through it after a null costs a second `gcloud run services describe` that is
29
+ * already known to answer nothing.
30
+ */
31
+ export function localUrl(config: Config): string {
32
+ return `http://${config.instance.host}:${config.instance.port}/mcp`;
33
+ }
34
+
35
+ /**
36
+ * Who is answering at this URL, if anyone.
37
+ *
38
+ * Shared for the reason `endpointUrl` above is: two workspaces can assign the
39
+ * same port, so an endpoint answering is not the same as *this* profile's
40
+ * endpoint answering. A command that skips this check reports another
41
+ * workspace's surface under this profile's heading.
42
+ *
43
+ * Anonymous would be enough to prove a socket is bound, but the profile list is
44
+ * behind the token, and the profile is the whole point of asking.
45
+ */
46
+ export async function endpointHealth(url: string, token: string): Promise<EndpointHealth | null> {
47
+ try {
48
+ const probe = new URL(url);
49
+ probe.pathname = '/health';
50
+ const response = await fetch(probe, {
51
+ headers: { authorization: `Bearer ${token}` },
52
+ signal: AbortSignal.timeout(700),
53
+ });
54
+ if (!response.ok) return null;
55
+
56
+ const body = (await response.json()) as Partial<EndpointHealth>;
57
+ return body.profile ? { profile: body.profile, profiles: body.profiles ?? [body.profile] } : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export interface EndpointHealth {
64
+ readonly profile: string;
65
+ readonly profiles: readonly string[];
21
66
  }
22
67
 
23
68
  /**
package/src/cli/main.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  status,
15
15
  tokenRotate,
16
16
  tokenShow,
17
+ tools,
17
18
  } from './commands/operate.ts';
18
19
  import { profileAdd, profileDefault, profileList } from './commands/profile.ts';
19
20
  import { removeProfile as profileRemove } from './commands/profile/remove.ts';
@@ -250,6 +251,13 @@ export async function run(argv: readonly string[]): Promise<void> {
250
251
  case 'outputs':
251
252
  return outputs({ ...global, show, json });
252
253
 
254
+ // Beside `outputs` because it answers the next question. `outputs` says
255
+ // where the endpoint is; this says what it would hand a client that asked
256
+ // right now — which is the only way to tell a stale client from a wrong
257
+ // endpoint without reading request sizes out of a log.
258
+ case 'tools':
259
+ return tools({ ...global, json });
260
+
253
261
  // Undocumented alias for `mcp skill`, which is where it moved when `skills`
254
262
  // arrived. Anyone who learned the old spelling keeps it.
255
263
  case 'skill':
@@ -29,6 +29,14 @@ export interface PublishOutcome {
29
29
  readonly published?: string;
30
30
  /** Whether a running endpoint confirmed it is now serving the edit. */
31
31
  readonly served: boolean;
32
+ /**
33
+ * How many tools the endpoint advertises now, when it answered.
34
+ *
35
+ * Reported rather than inferred, for the same reason `served` is: the number
36
+ * that matters is the one the endpoint would hand a client, and only the
37
+ * endpoint knows it.
38
+ */
39
+ readonly tools?: number;
32
40
  /** The endpoint that was told, or would have been. */
33
41
  readonly url?: string;
34
42
  /** Why it is not being served yet, in a form fit to print. */
@@ -142,7 +150,11 @@ async function notifyReload(input: {
142
150
  return { served: false, url, reason: `the endpoint answered ${response.status}` };
143
151
  }
144
152
 
145
- const body = (await response.json()) as { reloaded?: unknown; reason?: unknown };
153
+ const body = (await response.json()) as {
154
+ reloaded?: unknown;
155
+ reason?: unknown;
156
+ tools?: unknown;
157
+ };
146
158
  if (body.reloaded !== true) {
147
159
  return {
148
160
  served: false,
@@ -154,7 +166,11 @@ async function notifyReload(input: {
154
166
  };
155
167
  }
156
168
 
157
- return { served: true, url };
169
+ return {
170
+ served: true,
171
+ url,
172
+ ...(typeof body.tools === 'number' ? { tools: body.tools } : {}),
173
+ };
158
174
  } catch {
159
175
  // Nothing listening, scaled to zero, or unreachable from here — all of
160
176
  // which resolve themselves the next time the endpoint starts, because the
@@ -175,7 +191,34 @@ function message(error: unknown): string {
175
191
  * is now: the endpoint either answered or it did not.
176
192
  */
177
193
  export function nextAfterEdit(outcome: PublishOutcome): string {
178
- if (outcome.served) return 'Serving it now — the endpoint has re-read its config.';
194
+ if (outcome.served) {
195
+ const served = 'Serving it now — the endpoint has re-read its config.';
196
+ if (outcome.tools === undefined) return served;
197
+
198
+ // The second half of the truth, and the half an operator is actually
199
+ // looking at. The endpoint re-reading its config is not the same event as
200
+ // the client in front of them learning about it: a client fetches
201
+ // `tools/list` when it connects and holds the answer, and this endpoint
202
+ // cannot tell it otherwise — it is stateless, so there is no stream on
203
+ // which to send `notifications/tools/list_changed`, and it no longer claims
204
+ // there is (ADR-032).
205
+ //
206
+ // So the tool count goes here, where the change happened, and so does the
207
+ // one action that picks it up. Without this line the command reports
208
+ // success and the operator watches a connector that never changes.
209
+ //
210
+ // Worded for either direction, because `policy deny` prints this too and a
211
+ // deny is the case this file already calls "the one kind of staleness worth
212
+ // being strict about". "Pick them up" was written for a `connect` and read
213
+ // as nonsense after a deny, where the client is holding one tool too many
214
+ // rather than one too few — and where the stale entry is a tool the model
215
+ // will keep calling until it is gone.
216
+ return (
217
+ `${served}\n` +
218
+ ` ${outcome.tools} tools are advertised now. A client connected before this is still\n` +
219
+ ` holding the list it fetched then — reconnect it to match.`
220
+ );
221
+ }
179
222
 
180
223
  // Naming the URL, because the likeliest reason nothing answered is that the
181
224
  // endpoint is somewhere else: `lanes link start --port` moves the socket
package/src/cli/usage.ts CHANGED
@@ -84,6 +84,7 @@ ${style.bold('Deploying')}
84
84
  ${style.bold('Inspection')}
85
85
  ${PROGRAM} check static validation, no external calls
86
86
  ${PROGRAM} doctor [--json] credentials resolve, stores reachable
87
+ ${PROGRAM} tools [--json] what the endpoint advertises to a client
87
88
  ${PROGRAM} plan what reconcile would change
88
89
  ${PROGRAM} audit tail [--limit N] [--denied-only] [--format md]
89
90
  ${PROGRAM} audit verify has anything in the log been altered or removed
@@ -211,11 +211,38 @@ export async function deploy(flags: DeployFlags): Promise<void> {
211
211
  print(` ${url}/mcp`);
212
212
  print(await healthLine(url));
213
213
  print('');
214
- print(style.dim(` Register it with: lanes link outputs --target ${target}`));
214
+ print(registerLine(target));
215
215
 
216
216
  reportUnauthorised(prepared.warnings, target);
217
217
  }
218
218
 
219
+ /**
220
+ * How to register the endpoint, and when.
221
+ *
222
+ * The ordering is the whole point of the second half. A client captures
223
+ * `tools/list` when it connects and keeps it: this endpoint is stateless, so
224
+ * there is no stream on which to send `notifications/tools/list_changed`, and
225
+ * `buildMcpServer` no longer pretends otherwise. A first deploy necessarily
226
+ * publishes a profile whose only connection is `setup.main` — the accounts come
227
+ * after — so a connector registered in that window captures a two-tool surface
228
+ * and holds it. The endpoint is right, every reload lands, and the client shows
229
+ * two tools until someone removes and re-adds it.
230
+ *
231
+ * Unconditional, and that is the correction that matters. This was gated on
232
+ * `prepared.warnings.length`, which is zero in precisely the case it describes:
233
+ * a fresh profile declares only `setup.main`, `setup` is a local provider with
234
+ * no credential, so `prepareSecrets` has nothing to warn about. The advice
235
+ * appeared only on a later re-deploy, by which point the connector is usually
236
+ * registered and the ordering is no longer available to get right.
237
+ */
238
+ function registerLine(target: string): string {
239
+ return style.dim(
240
+ ` Connect your accounts first, then register with: lanes link outputs --target ${target}\n` +
241
+ ' A client keeps the tool list it fetched when it connected, so one registered\n' +
242
+ ' before the accounts holds a surface without them until it is re-added.',
243
+ );
244
+ }
245
+
219
246
  /**
220
247
  * The accounts a browser still has to authorise, and the step after them.
221
248
  *
@@ -267,7 +267,13 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
267
267
  close: () => closeAll(reopened.runtimes).then(() => {}),
268
268
  };
269
269
  },
270
- { primary: primary.resolution.profile, log: { debug() {}, info() {}, warn() {}, error() {} } },
270
+ // The same logger the request handler gets, rather than the inline no-op
271
+ // this used to be. What a generation has to say is exactly what nobody
272
+ // could see when a reload went wrong: `could not reload config`, `could
273
+ // not refresh skills`, and every `mcp handler error` the endpoint raises
274
+ // all went to those empty methods. A silent endpoint is not a quiet one —
275
+ // it is one whose failures have to be reconstructed from request sizes.
276
+ { primary: primary.resolution.profile, log: options.log ?? silentLogger() },
271
277
  );
272
278
 
273
279
  const server = serve({
@@ -282,6 +288,10 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
282
288
  ...(options.host !== undefined ? { host: options.host } : {}),
283
289
  });
284
290
 
291
+ // After `serve()`, so the record means the socket is bound. Recording it
292
+ // from the constructor claimed an endpoint that a failed bind never served.
293
+ generations.announce();
294
+
285
295
  return {
286
296
  url: server.url,
287
297
  profiles: [...runtimes.keys()],
@@ -0,0 +1,217 @@
1
+ import { createMcpHandler, type McpHttpHandler, type McpRequestContext } from '@modelcontextprotocol/server';
2
+ import { ownerPrincipal, type Principal } from '#auth';
3
+ import {
4
+ buildMcpServer,
5
+ toolNameFor,
6
+ visibleCapabilities,
7
+ visibleToolCount,
8
+ type ProfileRuntime,
9
+ } from '#server/mcp';
10
+ import type { GenerationDeps, OpenedWorkspace } from './generations.ts';
11
+
12
+ /**
13
+ * One boot's worth of runtimes, and everything derived from them.
14
+ *
15
+ * Immutable in what it serves. The memos inside still recompute on
16
+ * `registry.revision`, because skills can be replaced *within* a generation
17
+ * (ADR-014) — that is the one mutable surface the registry has, and it predates
18
+ * this.
19
+ *
20
+ * Lives beside `generations.ts` rather than in it: that file is about which
21
+ * generation is current and what a reload does to it, and this one is about
22
+ * what a generation holds. Neither needs the other's detail.
23
+ */
24
+ export class Generation {
25
+ readonly epoch: number;
26
+ readonly profiles: ReadonlyMap<string, ProfileRuntime>;
27
+
28
+ readonly #opened: OpenedWorkspace;
29
+ readonly #deps: GenerationDeps;
30
+ readonly #handlers = new Map<string, McpHttpHandler>();
31
+
32
+ /** In-flight requests pinned to this generation. */
33
+ #pins = 0;
34
+ /** Replaced by a newer generation, so it closes when the last pin drops. */
35
+ #retired = false;
36
+ #closed = false;
37
+
38
+ /** How stale a registry may be before the next request re-reads its skills. */
39
+ static readonly SKILL_POLL_MS = 2_000;
40
+ #polledAt = 0;
41
+
42
+ /**
43
+ * Every capability id across every profile, granted or not.
44
+ *
45
+ * Used only to spell a refusal correctly: a tool that exists but is not
46
+ * permitted should appear in the audit under its real id.
47
+ */
48
+ readonly allCapabilityIds: () => readonly string[];
49
+
50
+ /**
51
+ * Wire names the endpoint advertises.
52
+ *
53
+ * M1 has a single principal per profile, so this set does not vary by caller.
54
+ * When delegated principals arrive it becomes a per-principal lookup; the call
55
+ * site already reads as one.
56
+ */
57
+ readonly visible: () => ReadonlySet<string>;
58
+
59
+ /**
60
+ * How many tools this generation advertises (ADR-032).
61
+ *
62
+ * Not `visible().size`: that set spans every reachable capability, and a
63
+ * resource or a prompt is in it without being in `tools/list`.
64
+ */
65
+ readonly toolCount: () => number;
66
+
67
+ constructor(epoch: number, opened: OpenedWorkspace, deps: GenerationDeps) {
68
+ this.epoch = epoch;
69
+ this.profiles = opened.profiles;
70
+ this.#opened = opened;
71
+ this.#deps = deps;
72
+
73
+ this.allCapabilityIds = this.#memo(() => [
74
+ ...new Set(
75
+ [...this.profiles.values()].flatMap((runtime) =>
76
+ runtime.registry.capabilities().map(({ id }) => id),
77
+ ),
78
+ ),
79
+ ]);
80
+
81
+ this.visible = this.#memo(
82
+ () =>
83
+ new Set(
84
+ visibleCapabilities({
85
+ profiles: this.profiles,
86
+ principal: ownerPrincipal(deps.primary),
87
+ }).map(toolNameFor),
88
+ ),
89
+ );
90
+
91
+ this.toolCount = this.#memo(() =>
92
+ visibleToolCount({ profiles: this.profiles, principal: ownerPrincipal(deps.primary) }),
93
+ );
94
+ }
95
+
96
+ /** The profile names this generation serves, in declaration order. */
97
+ names(): string[] {
98
+ return [...this.profiles.keys()];
99
+ }
100
+
101
+ pin(): void {
102
+ this.#pins += 1;
103
+ }
104
+
105
+ /** Drop a pin, closing the generation if it was retired and this was the last. */
106
+ async unpin(): Promise<void> {
107
+ this.#pins -= 1;
108
+ if (this.#retired && this.#pins <= 0) await this.close();
109
+ }
110
+
111
+ /** Mark superseded. Closes immediately when nothing is using it. */
112
+ async retire(): Promise<void> {
113
+ this.#retired = true;
114
+ if (this.#pins <= 0) await this.close();
115
+ }
116
+
117
+ async close(): Promise<void> {
118
+ if (this.#closed) return;
119
+ this.#closed = true;
120
+
121
+ await Promise.all([...this.#handlers.values()].map((handler) => handler.close()));
122
+ this.#handlers.clear();
123
+ await this.#opened.close();
124
+ }
125
+
126
+ /**
127
+ * Work derived from the registries, recomputed when one of them changes.
128
+ *
129
+ * Skills can be replaced in a registry (ADR-014), and a stale `visible()` is
130
+ * not cosmetic: it gates the refusal-audit path, so a newly added skill would
131
+ * be recorded as a refusal on its first `prompts/get` even though the call
132
+ * succeeded.
133
+ */
134
+ #generation(): number {
135
+ return [...this.profiles.values()].reduce(
136
+ (total, runtime) => total + runtime.registry.revision,
137
+ 0,
138
+ );
139
+ }
140
+
141
+ #memo<T>(compute: () => T): () => T {
142
+ let at = -1;
143
+ let value: T;
144
+ return () => {
145
+ const now = this.#generation();
146
+ if (now !== at) {
147
+ value = compute();
148
+ at = now;
149
+ }
150
+ return value;
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Re-read the skills, at most once per poll interval.
156
+ *
157
+ * A skill written elsewhere — `lanes link skills add` in another terminal —
158
+ * cannot announce itself, so the endpoint has to look. Bounded rather than
159
+ * per-request because looking costs a `list()`, which on S3 is a network call.
160
+ * A write made *through* MCP does not wait for this; it refreshes directly.
161
+ */
162
+ async refreshSkills(): Promise<void> {
163
+ const now = Date.now();
164
+ if (now - this.#polledAt < Generation.SKILL_POLL_MS) return;
165
+ this.#polledAt = now;
166
+
167
+ await Promise.all(
168
+ [...this.profiles.values()].map(async (runtime) => {
169
+ try {
170
+ await runtime.refreshSkills?.();
171
+ } catch (error) {
172
+ // A skills directory that has gone unreadable, or one skill file
173
+ // someone is mid-edit, must not take the endpoint down with it. The
174
+ // previously loaded skills stay registered.
175
+ this.#deps.log.warn('could not refresh skills', { message: (error as Error).message });
176
+ }
177
+ }),
178
+ );
179
+ }
180
+
181
+ /**
182
+ * One handler per (principal, client label), memoised within this generation.
183
+ *
184
+ * The MCP surface depends only on resolved policy, so rebuilding the wiring
185
+ * per request would be pure waste. Reuse is safe because `createMcpHandler`
186
+ * still constructs a fresh server instance per request — what is memoised is
187
+ * the factory wiring, never session state. Memoised *here* rather than on the
188
+ * request handler because the factory closes over this generation's profiles:
189
+ * a handler outliving its generation is the stale-config bug.
190
+ */
191
+ handlerFor(principal: Principal, clientLabel: string | undefined): McpHttpHandler {
192
+ const key = `${principal.id}\u0000${clientLabel ?? ''}`;
193
+ const existing = this.#handlers.get(key);
194
+ if (existing) return existing;
195
+
196
+ const handler = createMcpHandler(
197
+ // The principal is closed over rather than read back out of `authInfo`:
198
+ // this handler is already keyed on it, and re-deriving identity from a
199
+ // field the SDK treats as opaque pass-through would create a second
200
+ // source of truth for who is calling.
201
+ (_context: McpRequestContext) =>
202
+ buildMcpServer({
203
+ profiles: this.profiles,
204
+ principal,
205
+ clientLabel,
206
+ ...(this.#deps.version ? { version: this.#deps.version } : {}),
207
+ }),
208
+ {
209
+ onerror: (error: Error) =>
210
+ this.#deps.log.error('mcp handler error', { message: error.message }),
211
+ },
212
+ );
213
+
214
+ this.#handlers.set(key, handler);
215
+ return handler;
216
+ }
217
+ }
@@ -1,20 +1,15 @@
1
- import {
2
- createMcpHandler,
3
- type McpHttpHandler,
4
- type McpRequestContext,
5
- } from '@modelcontextprotocol/server';
6
- import { ownerPrincipal, type Principal } from '#auth';
7
1
  import type { Logger } from '#connectivity';
8
2
  import { clearUpstreamTokens } from '#connectivity/auth/index.ts';
9
- import {
10
- buildMcpServer,
11
- toolNameFor,
12
- visibleCapabilities,
13
- type ProfileRuntime,
14
- } from '#server/mcp';
3
+ import type { ProfileRuntime } from '#server/mcp';
4
+ import { Generation } from './generation.ts';
15
5
 
16
6
  /**
17
- * Which runtimes are current, and when the ones they replaced are closed.
7
+ * Which generation is current, and the reload that replaces it.
8
+ *
9
+ * The generation itself — one boot's runtimes and every cache derived from
10
+ * them — is `generation.ts`. Split when this file outgrew its budget, along the
11
+ * seam it already had: what a generation *is* is a different subject from when
12
+ * one is swapped, and only the latter needs to know about reloading at all.
18
13
  *
19
14
  * This is a different subject from what an HTTP request does, which is why it
20
15
  * is not in `index.ts`. The endpoint used to hold one map of profile runtimes
@@ -59,202 +54,18 @@ export interface GenerationDeps {
59
54
  readonly version?: string | undefined;
60
55
  }
61
56
 
62
- /**
63
- * One boot's worth of runtimes, and everything derived from them.
64
- *
65
- * Immutable in what it serves. The memos inside still recompute on
66
- * `registry.revision`, because skills can be replaced *within* a generation
67
- * (ADR-014) — that is the one mutable surface the registry has, and it predates
68
- * this.
69
- */
70
- export class Generation {
71
- readonly epoch: number;
72
- readonly profiles: ReadonlyMap<string, ProfileRuntime>;
73
-
74
- readonly #opened: OpenedWorkspace;
75
- readonly #deps: GenerationDeps;
76
- readonly #handlers = new Map<string, McpHttpHandler>();
77
-
78
- /** In-flight requests pinned to this generation. */
79
- #pins = 0;
80
- /** Replaced by a newer generation, so it closes when the last pin drops. */
81
- #retired = false;
82
- #closed = false;
83
-
84
- /** How stale a registry may be before the next request re-reads its skills. */
85
- static readonly SKILL_POLL_MS = 2_000;
86
- #polledAt = 0;
87
-
88
- /**
89
- * Every capability id across every profile, granted or not.
90
- *
91
- * Used only to spell a refusal correctly: a tool that exists but is not
92
- * permitted should appear in the audit under its real id.
93
- */
94
- readonly allCapabilityIds: () => readonly string[];
95
-
96
- /**
97
- * Wire names the endpoint advertises.
98
- *
99
- * M1 has a single principal per profile, so this set does not vary by caller.
100
- * When delegated principals arrive it becomes a per-principal lookup; the call
101
- * site already reads as one.
102
- */
103
- readonly visible: () => ReadonlySet<string>;
104
-
105
- constructor(epoch: number, opened: OpenedWorkspace, deps: GenerationDeps) {
106
- this.epoch = epoch;
107
- this.profiles = opened.profiles;
108
- this.#opened = opened;
109
- this.#deps = deps;
110
-
111
- this.allCapabilityIds = this.#memo(() => [
112
- ...new Set(
113
- [...this.profiles.values()].flatMap((runtime) =>
114
- runtime.registry.capabilities().map(({ id }) => id),
115
- ),
116
- ),
117
- ]);
118
-
119
- this.visible = this.#memo(
120
- () =>
121
- new Set(
122
- visibleCapabilities({
123
- profiles: this.profiles,
124
- principal: ownerPrincipal(deps.primary),
125
- }).map(toolNameFor),
126
- ),
127
- );
128
- }
129
-
130
- /** The profile names this generation serves, in declaration order. */
131
- names(): string[] {
132
- return [...this.profiles.keys()];
133
- }
134
-
135
- pin(): void {
136
- this.#pins += 1;
137
- }
138
-
139
- /** Drop a pin, closing the generation if it was retired and this was the last. */
140
- async unpin(): Promise<void> {
141
- this.#pins -= 1;
142
- if (this.#retired && this.#pins <= 0) await this.close();
143
- }
144
-
145
- /** Mark superseded. Closes immediately when nothing is using it. */
146
- async retire(): Promise<void> {
147
- this.#retired = true;
148
- if (this.#pins <= 0) await this.close();
149
- }
150
-
151
- async close(): Promise<void> {
152
- if (this.#closed) return;
153
- this.#closed = true;
154
-
155
- await Promise.all([...this.#handlers.values()].map((handler) => handler.close()));
156
- this.#handlers.clear();
157
- await this.#opened.close();
158
- }
159
-
160
- /**
161
- * Work derived from the registries, recomputed when one of them changes.
162
- *
163
- * Skills can be replaced in a registry (ADR-014), and a stale `visible()` is
164
- * not cosmetic: it gates the refusal-audit path, so a newly added skill would
165
- * be recorded as a refusal on its first `prompts/get` even though the call
166
- * succeeded.
167
- */
168
- #generation(): number {
169
- return [...this.profiles.values()].reduce(
170
- (total, runtime) => total + runtime.registry.revision,
171
- 0,
172
- );
173
- }
174
-
175
- #memo<T>(compute: () => T): () => T {
176
- let at = -1;
177
- let value: T;
178
- return () => {
179
- const now = this.#generation();
180
- if (now !== at) {
181
- value = compute();
182
- at = now;
183
- }
184
- return value;
185
- };
186
- }
187
-
188
- /**
189
- * Re-read the skills, at most once per poll interval.
190
- *
191
- * A skill written elsewhere — `lanes link skills add` in another terminal —
192
- * cannot announce itself, so the endpoint has to look. Bounded rather than
193
- * per-request because looking costs a `list()`, which on S3 is a network call.
194
- * A write made *through* MCP does not wait for this; it refreshes directly.
195
- */
196
- async refreshSkills(): Promise<void> {
197
- const now = Date.now();
198
- if (now - this.#polledAt < Generation.SKILL_POLL_MS) return;
199
- this.#polledAt = now;
200
-
201
- await Promise.all(
202
- [...this.profiles.values()].map(async (runtime) => {
203
- try {
204
- await runtime.refreshSkills?.();
205
- } catch (error) {
206
- // A skills directory that has gone unreadable, or one skill file
207
- // someone is mid-edit, must not take the endpoint down with it. The
208
- // previously loaded skills stay registered.
209
- this.#deps.log.warn('could not refresh skills', { message: (error as Error).message });
210
- }
211
- }),
212
- );
213
- }
214
-
215
- /**
216
- * One handler per (principal, client label), memoised within this generation.
217
- *
218
- * The MCP surface depends only on resolved policy, so rebuilding the wiring
219
- * per request would be pure waste. Reuse is safe because `createMcpHandler`
220
- * still constructs a fresh server instance per request — what is memoised is
221
- * the factory wiring, never session state. Memoised *here* rather than on the
222
- * request handler because the factory closes over this generation's profiles:
223
- * a handler outliving its generation is the stale-config bug.
224
- */
225
- handlerFor(principal: Principal, clientLabel: string | undefined): McpHttpHandler {
226
- const key = `${principal.id}\u0000${clientLabel ?? ''}`;
227
- const existing = this.#handlers.get(key);
228
- if (existing) return existing;
229
-
230
- const handler = createMcpHandler(
231
- // The principal is closed over rather than read back out of `authInfo`:
232
- // this handler is already keyed on it, and re-deriving identity from a
233
- // field the SDK treats as opaque pass-through would create a second
234
- // source of truth for who is calling.
235
- (_context: McpRequestContext) =>
236
- buildMcpServer({
237
- profiles: this.profiles,
238
- principal,
239
- clientLabel,
240
- ...(this.#deps.version ? { version: this.#deps.version } : {}),
241
- }),
242
- {
243
- onerror: (error: Error) =>
244
- this.#deps.log.error('mcp handler error', { message: error.message }),
245
- },
246
- );
247
-
248
- this.#handlers.set(key, handler);
249
- return handler;
250
- }
251
- }
252
-
253
57
  /** What a reload did, as the `/reload` route reports it. */
254
58
  export interface ReloadResult {
255
59
  readonly reloaded: boolean;
256
60
  readonly epoch: number;
257
61
  readonly profiles: readonly string[];
62
+ /**
63
+ * How many tools the generation now serving advertises (ADR-032).
64
+ *
65
+ * What `connect` prints: the edit landing and the surface a client sees are
66
+ * different events, and the gap is where a stale tool list hides.
67
+ */
68
+ readonly tools: number;
258
69
  /** Why it did not reload. Absent on success. */
259
70
  readonly reason?: string;
260
71
  }
@@ -280,6 +91,29 @@ export class Generations {
280
91
  this.#deps = deps;
281
92
  }
282
93
 
94
+ /**
95
+ * Record what the current generation advertises.
96
+ *
97
+ * Called by the endpoint once the socket is bound, not from the constructor
98
+ * where it used to be: a `serving` record written before `serve()` returns is
99
+ * a claim about an endpoint that a failed bind means never served, and a
100
+ * crash-looping revision emitted one per attempt. `advertising` rather than
101
+ * `serving` because the container writes its own `serving <url>` to the same
102
+ * stream, and one word covering two different records is a filter that
103
+ * returns both and distinguishes neither.
104
+ *
105
+ * The boot emission is the more useful of the two: a fresh revision comes up
106
+ * holding whatever config held at deploy time — often one `setup.main` and
107
+ * two tools — and a client registered in that window keeps what it was
108
+ * handed. This is what makes that window visible afterwards (ADR-032).
109
+ */
110
+ announce(): void {
111
+ this.#deps.log.info('advertising', {
112
+ epoch: this.#current.epoch,
113
+ tools: this.#current.toolCount(),
114
+ });
115
+ }
116
+
283
117
  get current(): Generation {
284
118
  return this.#current;
285
119
  }
@@ -330,6 +164,7 @@ export class Generations {
330
164
  reloaded: false,
331
165
  epoch: previous.epoch,
332
166
  profiles: previous.names(),
167
+ tools: previous.toolCount(),
333
168
  reason,
334
169
  };
335
170
  }
@@ -337,21 +172,44 @@ export class Generations {
337
172
  this.#epoch += 1;
338
173
  this.#current = new Generation(this.#epoch, opened, this.#deps);
339
174
 
340
- // Module-global and keyed per connection, so it survives a reload that
341
- // replaced everything else. Re-connecting `<provider>.<id>` to a different
342
- // account would otherwise serve the previous account's access token until
343
- // it expired up to an hour after the config said otherwise. Unchanged
344
- // connections pay one refresh.
345
- clearUpstreamTokens();
175
+ // Past this line the reload has *landed*: the new generation is what
176
+ // requests get. Everything below is tidying and reporting, and none of it
177
+ // can un-land the swap so a throw here must not be reported as a failed
178
+ // reload. It would be: `/reload` has no try/catch of its own, so the
179
+ // exception becomes a 500, and `connect` reads that as "saved, and the
180
+ // endpoint will serve this when it next starts" for config the endpoint is
181
+ // already serving. `previous.retire()` closes the audit log, which on a
182
+ // cloud target is a network write, so this is reachable rather than
183
+ // theoretical.
184
+ try {
185
+ // Module-global and keyed per connection, so it survives a reload that
186
+ // replaced everything else. Re-connecting `<provider>.<id>` to a different
187
+ // account would otherwise serve the previous account's access token until
188
+ // it expired — up to an hour after the config said otherwise. Unchanged
189
+ // connections pay one refresh.
190
+ clearUpstreamTokens();
191
+
192
+ // After the swap: a request arriving during the retire already gets the
193
+ // new generation, and this only waits on requests that started before it.
194
+ await previous.retire();
195
+ } catch (error) {
196
+ this.#deps.log.error('reload landed, but retiring the previous generation failed', {
197
+ message: error instanceof Error ? error.message : String(error),
198
+ });
199
+ }
200
+
201
+ const tools = this.#current.toolCount();
346
202
 
347
- // After the swap: a request arriving during the retire already gets the new
348
- // generation, and this only waits on requests that started before it.
349
- await previous.retire();
203
+ // The only record of what the endpoint advertises: `tools/list` is neither
204
+ // logged nor audited, so nothing else could tell a generation serving two
205
+ // tools from one serving forty. Once per reload, on a memoised read.
206
+ this.#deps.log.info('advertising', { epoch: this.#current.epoch, tools });
350
207
 
351
208
  return {
352
209
  reloaded: true,
353
210
  epoch: this.#current.epoch,
354
211
  profiles: this.#current.names(),
212
+ tools,
355
213
  };
356
214
  }
357
215
 
@@ -252,6 +252,11 @@ export function startHarness(options: HarnessOptions): Harness {
252
252
  log,
253
253
  });
254
254
 
255
+ // As `startEndpoint` does, and after `serve()` for the same reason: the
256
+ // record means the socket is bound. Here because this harness claims to be
257
+ // the real wiring, and a boot step it omits is a boot step no test can see.
258
+ generations.announce();
259
+
255
260
  return {
256
261
  server,
257
262
  state,
@@ -3,7 +3,8 @@ import type { Logger } from '#connectivity';
3
3
  import { capabilityIdForToolName } from '#server/mcp';
4
4
  import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
5
5
  import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
6
- import type { Generation, Generations } from './generations.ts';
6
+ import type { Generation } from './generation.ts';
7
+ import type { Generations } from './generations.ts';
7
8
  import { callerKey, failedAuthLimiter, FAILED_AUTH_PER_MINUTE, tooManyAttempts } from './edge.ts';
8
9
  import {
9
10
  handleAuthorization,
@@ -45,7 +45,42 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
45
45
  // Second argument, not the first: `instructions` is a `ServerOptions` field,
46
46
  // and `Implementation` would take it as an unknown extra and drop it from
47
47
  // `initialize` without complaining.
48
- { instructions: serverInstructions(names, merged) },
48
+ {
49
+ instructions: serverInstructions(names, merged),
50
+ // Declared `false` because it is false, and the SDK defaults it to `true`.
51
+ //
52
+ // `listChanged` is a promise to send `notifications/tools/list_changed`
53
+ // when the surface changes. This endpoint cannot keep it: it is stateless
54
+ // streamable HTTP, so there is no stream to deliver a notification on and
55
+ // this very server instance is discarded once the response is written.
56
+ // Nothing in `src/` sends one, and nothing can.
57
+ //
58
+ // Leaving the default on is not a harmless inaccuracy. A client that
59
+ // believes it will be told has no reason to ask again, so it keeps the
60
+ // list it captured when it first connected — and the surface here grows
61
+ // every time an account is connected (ADR-029). That combination cost a
62
+ // connector registered before `connect` ran its whole tool list: it held
63
+ // the two setup tools for as long as it lived, and no refresh replaced
64
+ // them, because the refresh was an `initialize` that never went on to ask
65
+ // for `tools/list`.
66
+ //
67
+ // Saying `false` costs a re-list per session and buys a surface that is
68
+ // never stale.
69
+ //
70
+ // `tools` unconditionally, because the argument above is about tools and
71
+ // an endpoint that serves none should still answer "none right now"
72
+ // rather than "this server does not do tools". Resources and prompts only
73
+ // when the profile has some: declaring a capability installs its handler
74
+ // set, so a client that gates its list calls on what is advertised would
75
+ // otherwise issue `resources/list`, `resources/templates/list` and
76
+ // `prompts/list` — three stateless POSTs, each rebuilding the whole
77
+ // server — to be told nothing is there.
78
+ capabilities: {
79
+ tools: { listChanged: false },
80
+ ...(offers(merged, isResource) ? { resources: { listChanged: false } } : {}),
81
+ ...(offers(merged, isPrompt) ? { prompts: { listChanged: false } } : {}),
82
+ },
83
+ },
49
84
  );
50
85
 
51
86
  for (const [id, entry] of merged) {
@@ -66,3 +101,22 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
66
101
 
67
102
  return server;
68
103
  }
104
+
105
+ /**
106
+ * Whether any reachable capability registers as this kind.
107
+ *
108
+ * Reads the same `merged` map the registration loop below consumes and asks it
109
+ * the same question `isResource`/`isPrompt` answer there, so what is advertised
110
+ * and what is registered cannot disagree. A discovered capability is always a
111
+ * tool, which is why it is not consulted here.
112
+ */
113
+ function offers(
114
+ merged: ReturnType<typeof mergeCapabilities>,
115
+ kind: typeof isResource | typeof isPrompt,
116
+ ): boolean {
117
+ for (const entry of merged.values()) {
118
+ if (!entry.discovered && entry.capability && kind(entry.capability)) return true;
119
+ }
120
+
121
+ return false;
122
+ }
@@ -26,6 +26,7 @@ export {
26
26
  mergeCapabilities,
27
27
  oneProfile,
28
28
  visibleCapabilities,
29
+ visibleToolCount,
29
30
  type BuildServerOptions,
30
31
  type MergedCapability,
31
32
  type ProfileRuntime,
@@ -1,3 +1,4 @@
1
+ import { isTool } from '#connectivity';
1
2
  import type { Principal } from '#auth';
2
3
  import type { Config } from '#profile';
3
4
  import type { ProviderRegistry } from '#registry';
@@ -114,6 +115,27 @@ export function visibleCapabilities(options: BuildServerOptions): string[] {
114
115
  return [...mergeCapabilities(options).keys()];
115
116
  }
116
117
 
118
+ /**
119
+ * How many of those are tools, as `tools/list` would count them.
120
+ *
121
+ * Not the same number as `visibleCapabilities().length`, and the difference is
122
+ * the kind of thing that only shows up when someone reads it: a reachable
123
+ * capability may register as a resource or a prompt instead, so counting ids
124
+ * and calling the answer "tools" overstates the list by however many of those
125
+ * a profile has. The kind is decided here exactly as `buildMcpServer` decides
126
+ * it — a discovered capability is always a tool, an authored one is asked.
127
+ */
128
+ export function visibleToolCount(options: BuildServerOptions): number {
129
+ let count = 0;
130
+
131
+ for (const entry of mergeCapabilities(options).values()) {
132
+ if (entry.discovered) count += 1;
133
+ else if (entry.capability && isTool(entry.capability)) count += 1;
134
+ }
135
+
136
+ return count;
137
+ }
138
+
117
139
  /**
118
140
  * Say which accounts are reachable, grouped by profile.
119
141
  *