@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 +1 -1
- 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/main.ts +8 -0
- package/src/cli/publish.ts +46 -3
- package/src/cli/usage.ts +1 -0
- 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 +55 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/visibility.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { listProfiles } from '#profile';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { deployedUrl,
|
|
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
|
-
|
|
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
|
|
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';
|
package/src/cli/endpoint-url.ts
CHANGED
|
@@ -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 ??
|
|
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':
|
package/src/cli/publish.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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)
|
|
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(
|
|
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,
|
package/src/server/mcp/build.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
+
}
|
package/src/server/mcp/index.ts
CHANGED
|
@@ -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
|
*
|