@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.
- package/README.md +3 -3
- package/instructions/skills/lanes-link/SKILL.md +1 -1
- package/package.json +3 -3
- package/src/cli/commands/operate/outputs.ts +5 -38
- package/src/cli/commands/operate/tools.ts +343 -0
- package/src/cli/commands/operate.ts +1 -0
- package/src/cli/endpoint-url.ts +46 -1
- package/src/cli/lanes.ts +1 -1
- package/src/cli/main.ts +8 -0
- package/src/cli/publish.ts +46 -3
- package/src/cli/usage.ts +2 -1
- package/src/deployments/deploy.ts +28 -1
- package/src/server/endpoint.ts +11 -1
- package/src/server/generation.ts +217 -0
- package/src/server/generations.ts +71 -213
- package/src/server/harness.ts +5 -0
- package/src/server/index.ts +2 -1
- package/src/server/mcp/build.ts +56 -2
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/visibility.ts +22 -0
|
@@ -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(
|
|
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
|
*
|
package/src/server/endpoint.ts
CHANGED
|
@@ -267,7 +267,13 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
267
267
|
close: () => closeAll(reopened.runtimes).then(() => {}),
|
|
268
268
|
};
|
|
269
269
|
},
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
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
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
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
|
|
package/src/server/harness.ts
CHANGED
|
@@ -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,
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
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,
|