@lanes-sh/link 0.1.0 → 0.1.2

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.
@@ -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,