@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 CHANGED
@@ -1,15 +1,15 @@
1
1
  # Lanes Link
2
2
 
3
- **One secure endpoint between your AI agents and your real accounts, knowledge, and secrets.**
3
+ **One secure endpoint between your AI agents and all your connections, memory, skills, and secrets.**
4
4
 
5
5
  Connect your mail, calendar, files, and notes once, and add the memory and skills that only you
6
6
  have. Every agent you use — Claude, ChatGPT, and anything else that speaks MCP — reaches them
7
- through a single MCP endpoint that you own and run. Open source, self-hosted, no vendor sitting in
7
+ through a single MCP endpoint that you own and run. Open source, self-hostable, no vendor sitting in
8
8
  the middle of your data.
9
9
 
10
10
  <picture>
11
11
  <source media="(prefers-color-scheme: dark)" srcset="docs/images/lanes-link-dark.svg">
12
- <img alt="Claude, ChatGPT, and any MCP client reach one Lanes Link endpoint, which you run on your own machine or your own cloud. The profiles it serves sit inside that boundary: Work, holding Gmail, Calendar, and Docs; and Personal, holding Gmail, Memory, and Skills." src="docs/images/lanes-link-light.svg">
12
+ <img alt="Claude, Codex, and Gemini all reach one Lanes Link endpoint, which you run yourself. Beneath it sit the profiles it serves: personal, holding Gmail, Memory, and Skills; and work, holding Gmail, Calendar, and Docs." src="docs/images/lanes-link-light.svg">
13
13
  </picture>
14
14
 
15
15
  ## Why
@@ -5,7 +5,7 @@ description: Use when the user refers to their own accounts, knowledge, procedur
5
5
 
6
6
  # Lanes Link
7
7
 
8
- A self-hosted gateway to one person's own context: the accounts they have
8
+ A self-hostable gateway to one person's own context: the accounts they have
9
9
  connected, the knowledge they have accumulated, the procedures they have
10
10
  written down, and their secrets. One endpoint serves every profile in a
11
11
  workspace under one token, and each call names which profile it means.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.1.0",
4
- "description": "A self-hosted MCP gateway for your accounts, memory, skills, and secrets",
3
+ "version": "0.1.2",
4
+ "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
- "homepage": "https://github.com/lanes-sh/link#readme",
6
+ "homepage": "https://lanes.sh/link",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/lanes-sh/link.git"
@@ -1,6 +1,6 @@
1
1
  import { listProfiles } from '#profile';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { deployedUrl, endpointUrl } from '../../endpoint-url.ts';
3
+ import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
4
4
  import { announce, heading, print, style, warn } from '../../output.ts';
5
5
  import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
6
6
 
@@ -9,11 +9,6 @@ export interface OutputsFlags extends GlobalFlags {
9
9
  readonly json?: boolean | undefined;
10
10
  }
11
11
 
12
- interface Health {
13
- readonly profile: string;
14
- readonly profiles: readonly string[];
15
- }
16
-
17
12
  /**
18
13
  * What an agent harness needs to reach this endpoint.
19
14
  *
@@ -34,9 +29,11 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
34
29
  const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
35
30
  const declared = runtime.config.targets[runtime.target]?.deploy;
36
31
  const deployed = await deployedUrl(declared);
37
- const url = deployed ?? (await endpointUrl(runtime.config, runtime.target));
32
+ // Not `endpointUrl`, which would ask the platform a second time for an
33
+ // answer this line already has.
34
+ const url = deployed ?? localUrl(runtime.config);
38
35
 
39
- const live = await health(url, token);
36
+ const live = await endpointHealth(url, token);
40
37
  const mine = live?.profile === runtime.resolution.profile;
41
38
 
42
39
  // Live if it is up, otherwise what `start` would serve: the whole
@@ -174,33 +171,3 @@ async function tokenInvocation(
174
171
  return { command: `bun run ${entry} link token show --raw${suffix}`, onPath: false };
175
172
  }
176
173
 
177
- /**
178
- * Ask the endpoint who it is.
179
- *
180
- * The profile name is checked by the caller, not just the port: two workspaces
181
- * can assign the same port, and reporting "running" because something
182
- * unrelated answers would send someone to register an endpoint serving another
183
- * workspace's accounts.
184
- *
185
- * The token is sent because `/health` names profiles only to a caller that
186
- * holds one. Anonymously it answers `{status: "ok"}` and nothing else — that
187
- * list is what this endpoint holds, and a deployed URL is readable by anyone.
188
- * An endpoint that answers without naming itself is therefore reported as
189
- * something else's, which is the honest reading: this token does not open it.
190
- */
191
- async function health(url: string, token: string): Promise<Health | null> {
192
- try {
193
- const probe = new URL(url);
194
- probe.pathname = '/health';
195
- const response = await fetch(probe, {
196
- headers: { authorization: `Bearer ${token}` },
197
- signal: AbortSignal.timeout(700),
198
- });
199
- if (!response.ok) return null;
200
-
201
- const body = (await response.json()) as Partial<Health>;
202
- return body.profile ? { profile: body.profile, profiles: body.profiles ?? [body.profile] } : null;
203
- } catch {
204
- return null;
205
- }
206
- }
@@ -0,0 +1,343 @@
1
+ import { capabilityIdForToolName } from '#server/mcp';
2
+ import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
3
+ import { announce, emit, heading, print, style, warn } from '../../output.ts';
4
+ import { ensureProfileToken, openRuntime, type GlobalFlags } from '../../runtime.ts';
5
+
6
+ export interface ToolsFlags extends GlobalFlags {
7
+ readonly json?: boolean | undefined;
8
+ }
9
+
10
+ /**
11
+ * What a client is actually being told, asked over the wire.
12
+ *
13
+ * Not derived from the config, and that is the whole reason this exists.
14
+ * `doctor` answers whether the credentials resolve and `outputs` answers where
15
+ * the endpoint is; neither answers the question that follows a client showing
16
+ * the wrong tools, which is what the endpoint would hand it right now. Working
17
+ * that out previously meant hand-rolling a `tools/list` with `curl` and a token
18
+ * pulled out of the secret store, and reading a byte count out of a request log
19
+ * to guess at the answer.
20
+ *
21
+ * The `listChanged` line is here for the same reason. A client refreshes by
22
+ * asking again, and a server that claims it will announce changes gives it a
23
+ * reason not to — so a surface that looks stale in a client and current here is
24
+ * explained by that flag more often than by anything else (ADR-032).
25
+ */
26
+ export async function tools(flags: ToolsFlags): Promise<void> {
27
+ const runtime = await openRuntime(flags);
28
+
29
+ try {
30
+ const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
31
+ const declared = runtime.config.targets[runtime.target]?.deploy;
32
+ const deployed = await deployedUrl(declared);
33
+ // Not `endpointUrl`, which asks the platform a second time for an answer
34
+ // this line already has.
35
+ const url = deployed ?? localUrl(runtime.config);
36
+
37
+ // Before trusting anything the surface says: two workspaces can assign the
38
+ // same port, and `secrets push` makes their tokens match, so a `--target
39
+ // cloud` whose deployment could not be located answers from loopback with a
40
+ // token that works. Reporting that as the deployed endpoint's surface is
41
+ // the failure `endpoint-url.ts` calls "silent in the worst way".
42
+ const live = await endpointHealth(url, token);
43
+ const mine = live?.profile === runtime.resolution.profile;
44
+
45
+ const surface = await askEndpoint(url, token);
46
+ const providers = [...new Set(runtime.registry.capabilities().map(({ id }) => id))];
47
+
48
+ await emit(
49
+ flags.json,
50
+ {
51
+ url,
52
+ target: runtime.target,
53
+ // Both, because `--target cloud` reaching loopback is indistinguishable
54
+ // from success without them.
55
+ deployed: deployed !== null,
56
+ answering: mine,
57
+ ...surfaceJson(surface),
58
+ },
59
+ () => {
60
+ announce(runtime.resolution);
61
+
62
+ heading('Endpoint');
63
+ print(` ${url} ${reachability(mine, deployed !== null, surface)}`);
64
+
65
+ if (declared && !deployed) {
66
+ // The case the ownership probe cannot catch on its own: the address
67
+ // is loopback because the platform could not be asked, not because
68
+ // this target is local.
69
+ print(
70
+ warn(
71
+ `could not ask ${declared.platform} where "${declared.service}" is — this is the local endpoint, not the deployed one`,
72
+ ),
73
+ );
74
+ } else if (live && !mine) {
75
+ print(warn(`something else is serving this port: profile "${live.profile}"`));
76
+ }
77
+
78
+ if (!surface.reachable) {
79
+ print(fail(surface));
80
+ return;
81
+ }
82
+
83
+ heading(`Advertised to a client (${surface.names.length})`);
84
+ for (const [provider, names] of groupByProvider(surface.names, providers)) {
85
+ print(` ${style.bold(provider)} ${style.dim(`${names.length}`)}`);
86
+ for (const name of names) print(` ${name}`);
87
+ }
88
+
89
+ heading('How a client sees it');
90
+ print(` payload: ${kb(surface.bytes)} for the whole list`);
91
+ print(` listChanged: ${listChangedLine(surface.listChanged)}`);
92
+ print(
93
+ style.dim(
94
+ ' Tools only — a skill is a prompt, and is not counted here or by `connect`.',
95
+ ),
96
+ );
97
+ },
98
+ );
99
+
100
+ // Non-zero for the same reason `doctor` does it: a command whose whole job
101
+ // is to answer a question exits failing when it could not answer.
102
+ if (!surface.reachable) process.exitCode = 1;
103
+ } finally {
104
+ await runtime.close();
105
+ }
106
+ }
107
+
108
+ /**
109
+ * What is at this address, in one word.
110
+ *
111
+ * The refusal case is why this is not just `mine`. `/health` is asked with the
112
+ * same token, so an endpoint belonging to another workspace fails that probe
113
+ * too — and reporting "not running" above a refusal that begins "it is
114
+ * answering" is the command contradicting itself in two consecutive lines.
115
+ */
116
+ function reachability(mine: boolean, deployed: boolean, surface: Surface): string {
117
+ if (mine) return style.green(deployed ? 'deployed' : 'running');
118
+ if (surface.refused) return style.yellow('answering, but not for this token');
119
+ return style.dim(deployed ? 'not answering' : 'not running');
120
+ }
121
+
122
+ /**
123
+ * Why the surface could not be read, said as the thing that happened.
124
+ *
125
+ * Refused and unreachable are different problems with different fixes, and
126
+ * folding them together sends someone to check whether the endpoint is up when
127
+ * it answered them perfectly well and declined their token.
128
+ */
129
+ function fail(surface: Surface): string {
130
+ if (surface.refused) {
131
+ return (
132
+ warn(`the endpoint refused this token: ${surface.reason}`) +
133
+ `\n${style.dim(' It is answering. Either the token was rotated without re-registering, or\n this address belongs to another workspace.')}`
134
+ );
135
+ }
136
+
137
+ return (
138
+ warn(`could not ask it: ${surface.reason}`) +
139
+ `\n${style.dim(' Nothing here reads the config to guess instead — an endpoint that cannot\n be asked is exactly the case where a guess would be believed.')}`
140
+ );
141
+ }
142
+
143
+ interface Surface {
144
+ readonly reachable: boolean;
145
+ readonly reason?: string;
146
+ /** It answered, and declined. Distinct from not answering at all. */
147
+ readonly refused?: boolean;
148
+ readonly names: readonly string[];
149
+ readonly bytes: number;
150
+ readonly listChanged?: boolean | undefined;
151
+ }
152
+
153
+ /**
154
+ * `tools` as a count, matching `/reload` and `connect`; the names beside it.
155
+ *
156
+ * One key, one meaning. `ReloadResult.tools` and `PublishOutcome.tools` are both
157
+ * numbers, and the count is what `docs/connect.md` tells an operator to compare
158
+ * against their client — shipping the same key here as an array would make
159
+ * `.tools > 5` true for a single tool.
160
+ */
161
+ function surfaceJson(surface: Surface): Record<string, unknown> {
162
+ return {
163
+ reachable: surface.reachable,
164
+ ...(surface.reason !== undefined ? { reason: surface.reason } : {}),
165
+ ...(surface.refused !== undefined ? { refused: surface.refused } : {}),
166
+ tools: surface.names.length,
167
+ names: surface.names,
168
+ bytes: surface.bytes,
169
+ ...(surface.listChanged !== undefined ? { listChanged: surface.listChanged } : {}),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * One `initialize`, one `tools/list`, exactly as a 2025-era client sends them.
175
+ *
176
+ * Deliberately hand-rolled rather than run through an MCP client library: the
177
+ * subject is what the endpoint puts on the wire, and a library that negotiated
178
+ * a newer revision would answer a different question than the one asked.
179
+ */
180
+ export async function askEndpoint(url: string, token: string): Promise<Surface> {
181
+ const post = async (body: unknown): Promise<{ text: string; result: Record<string, unknown> }> => {
182
+ const response = await fetch(url, {
183
+ method: 'POST',
184
+ headers: {
185
+ 'content-type': 'application/json',
186
+ // Both, and not one: the streamable HTTP transport answers 406 to a
187
+ // client that will not accept an event stream, whatever it then sends.
188
+ accept: 'application/json, text/event-stream',
189
+ authorization: `Bearer ${token}`,
190
+ },
191
+ body: JSON.stringify(body),
192
+ signal: AbortSignal.timeout(30_000),
193
+ });
194
+
195
+ const text = await response.text();
196
+ if (!response.ok) throw new Refusal(response.status, text);
197
+
198
+ return { text, result: parse(text) };
199
+ };
200
+
201
+ try {
202
+ const init = await post({
203
+ jsonrpc: '2.0',
204
+ id: 1,
205
+ method: 'initialize',
206
+ params: {
207
+ protocolVersion: '2025-06-18',
208
+ capabilities: {},
209
+ clientInfo: { name: 'lanes-link-cli', version: '0.0.0' },
210
+ },
211
+ });
212
+
213
+ const capabilities = init.result['capabilities'] as
214
+ | { tools?: { listChanged?: boolean } }
215
+ | undefined;
216
+
217
+ const listed = await post({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
218
+ const names = ((listed.result['tools'] as Array<{ name?: string }> | undefined) ?? [])
219
+ .map((tool) => tool.name ?? '')
220
+ .filter((name) => name.length > 0)
221
+ .sort();
222
+
223
+ return {
224
+ reachable: true,
225
+ names,
226
+ bytes: Buffer.byteLength(listed.text),
227
+ listChanged: capabilities?.tools?.listChanged,
228
+ };
229
+ } catch (error) {
230
+ if (error instanceof Refusal) {
231
+ return { reachable: false, refused: error.refused, reason: error.message, names: [], bytes: 0 };
232
+ }
233
+
234
+ return {
235
+ reachable: false,
236
+ reason: error instanceof Error ? error.message : String(error),
237
+ names: [],
238
+ bytes: 0,
239
+ };
240
+ }
241
+ }
242
+
243
+ /** An endpoint that answered and would not serve this call. */
244
+ class Refusal extends Error {
245
+ readonly refused: boolean;
246
+
247
+ constructor(status: number, body: string) {
248
+ // The transport puts the actionable reason in a JSON-RPC error body for
249
+ // 400/406/415, and discarding it leaves only a number to act on.
250
+ const detail = errorMessage(body);
251
+ super(detail ? `${status} — ${detail}` : `answered ${status}`);
252
+ this.refused = status === 401 || status === 403;
253
+ }
254
+ }
255
+
256
+ function errorMessage(body: string): string | null {
257
+ try {
258
+ const parsed = JSON.parse(body) as { error?: { message?: string } | string };
259
+ if (typeof parsed.error === 'string') return parsed.error;
260
+ return parsed.error?.message ?? null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * The JSON payload of a response that may or may not be framed as SSE.
268
+ *
269
+ * Three shapes reach here and all three are legal. A plain JSON body; an event
270
+ * stream whose payload line is `data: {…}`; and one where the space after the
271
+ * colon is absent, which the spec permits. The stream may also open with
272
+ * comment lines — the transport arms a keep-alive on every POST, so a handler
273
+ * that runs long enough emits `: keepalive` before anything else — so the
274
+ * payload is found by scanning rather than by inspecting the first characters.
275
+ */
276
+ export function parse(text: string): Record<string, unknown> {
277
+ const data = text.split('\n').find((line) => line.startsWith('data:'));
278
+ const body = data === undefined ? text : data.slice('data:'.length).trim();
279
+
280
+ const message = JSON.parse(body) as {
281
+ result?: Record<string, unknown>;
282
+ error?: { message?: string };
283
+ };
284
+
285
+ if (message.error) throw new Error(message.error.message ?? 'the endpoint returned an error');
286
+ return message.result ?? {};
287
+ }
288
+
289
+ /**
290
+ * Group by provider, resolving each wire name back to the capability it is.
291
+ *
292
+ * A wire name is the capability id with its dots replaced (`naming.ts`), so
293
+ * `icloud_mail.send_message` arrives as `icloud_mail_send_message` and there is
294
+ * nothing left in the string to say where the provider ends. Splitting on the
295
+ * first underscore would file it under `icloud`, which is not a provider.
296
+ *
297
+ * `capabilityIdForToolName` answers it exactly against a set of known ids, and
298
+ * falls back to that first-underscore split when it recognises none — which is
299
+ * reachable here, because the registry is the *invoking profile's* while the
300
+ * endpoint may serve several, and under `--target cloud` may run an image this
301
+ * checkout does not have. So the fallback is detected rather than trusted: a
302
+ * name that resolves to nothing known is grouped as unattributed, because a
303
+ * guessed heading with a confident count is worse than an honest "these did not
304
+ * match anything I know about".
305
+ */
306
+ export function groupByProvider(
307
+ names: readonly string[],
308
+ capabilityIds: readonly string[],
309
+ ): Map<string, string[]> {
310
+ const known = new Set(capabilityIds);
311
+ const grouped = new Map<string, string[]>();
312
+
313
+ for (const name of names) {
314
+ const id = capabilityIdForToolName(name, capabilityIds);
315
+ const provider = known.has(id) ? id.slice(0, id.indexOf('.')) : UNATTRIBUTED;
316
+
317
+ const bucket = grouped.get(provider);
318
+ if (bucket) bucket.push(name);
319
+ else grouped.set(provider, [name]);
320
+ }
321
+
322
+ return new Map([...grouped].sort(([a], [b]) => a.localeCompare(b)));
323
+ }
324
+
325
+ /** Named rather than spelled inline, so the output and the test agree. */
326
+ export const UNATTRIBUTED = '(not in this profile)';
327
+
328
+ function kb(bytes: number): string {
329
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
330
+ }
331
+
332
+ function listChangedLine(declared: boolean | undefined): string {
333
+ if (declared === undefined) return style.dim('not declared');
334
+ if (declared === false) {
335
+ return `false ${style.dim('— a client re-reads this list rather than waiting to be told')}`;
336
+ }
337
+
338
+ // Worth a warning rather than a value, because it is the shape of a bug that
339
+ // presents as "the endpoint is wrong" when the endpoint is right: a client
340
+ // that trusts the promise keeps the list it first fetched, for as long as it
341
+ // is registered.
342
+ return style.yellow('true') + style.dim(' — but nothing here sends the notification');
343
+ }
@@ -20,6 +20,7 @@
20
20
  export { check, doctor, plan } from './operate/inspect.ts';
21
21
  export { status } from './operate/status.ts';
22
22
  export { outputs, type OutputsFlags } from './operate/outputs.ts';
23
+ export { tools, type ToolsFlags } from './operate/tools.ts';
23
24
  export { start } from './operate/serve.ts';
24
25
  export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
25
26
  export { attachFile } from './operate/attach.ts';
@@ -17,7 +17,52 @@ import type { Config, DeployConfig } from '#profile';
17
17
  */
18
18
  export async function endpointUrl(config: Config, target: string): Promise<string> {
19
19
  const deployed = await deployedUrl(config.targets[target]?.deploy);
20
- return deployed ?? `http://${config.instance.host}:${config.instance.port}/mcp`;
20
+ return deployed ?? localUrl(config);
21
+ }
22
+
23
+ /**
24
+ * Where `lanes link start` would listen, from config alone.
25
+ *
26
+ * Split out so a caller that has already asked `deployedUrl` can name the
27
+ * fallback without asking again — `endpointUrl` is the two together, and going
28
+ * through it after a null costs a second `gcloud run services describe` that is
29
+ * already known to answer nothing.
30
+ */
31
+ export function localUrl(config: Config): string {
32
+ return `http://${config.instance.host}:${config.instance.port}/mcp`;
33
+ }
34
+
35
+ /**
36
+ * Who is answering at this URL, if anyone.
37
+ *
38
+ * Shared for the reason `endpointUrl` above is: two workspaces can assign the
39
+ * same port, so an endpoint answering is not the same as *this* profile's
40
+ * endpoint answering. A command that skips this check reports another
41
+ * workspace's surface under this profile's heading.
42
+ *
43
+ * Anonymous would be enough to prove a socket is bound, but the profile list is
44
+ * behind the token, and the profile is the whole point of asking.
45
+ */
46
+ export async function endpointHealth(url: string, token: string): Promise<EndpointHealth | null> {
47
+ try {
48
+ const probe = new URL(url);
49
+ probe.pathname = '/health';
50
+ const response = await fetch(probe, {
51
+ headers: { authorization: `Bearer ${token}` },
52
+ signal: AbortSignal.timeout(700),
53
+ });
54
+ if (!response.ok) return null;
55
+
56
+ const body = (await response.json()) as Partial<EndpointHealth>;
57
+ return body.profile ? { profile: body.profile, profiles: body.profiles ?? [body.profile] } : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export interface EndpointHealth {
64
+ readonly profile: string;
65
+ readonly profiles: readonly string[];
21
66
  }
22
67
 
23
68
  /**
package/src/cli/lanes.ts CHANGED
@@ -18,7 +18,7 @@ import { version } from './version.ts';
18
18
  */
19
19
 
20
20
  const AREAS: Record<string, string> = {
21
- link: 'a self-hosted MCP gateway for your accounts, memory, skills, and secrets',
21
+ link: 'a self-hostable MCP gateway for all your connections, memory, skills, and secrets',
22
22
  };
23
23
 
24
24
  function areasUsage(): string {
package/src/cli/main.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  status,
15
15
  tokenRotate,
16
16
  tokenShow,
17
+ tools,
17
18
  } from './commands/operate.ts';
18
19
  import { profileAdd, profileDefault, profileList } from './commands/profile.ts';
19
20
  import { removeProfile as profileRemove } from './commands/profile/remove.ts';
@@ -250,6 +251,13 @@ export async function run(argv: readonly string[]): Promise<void> {
250
251
  case 'outputs':
251
252
  return outputs({ ...global, show, json });
252
253
 
254
+ // Beside `outputs` because it answers the next question. `outputs` says
255
+ // where the endpoint is; this says what it would hand a client that asked
256
+ // right now — which is the only way to tell a stale client from a wrong
257
+ // endpoint without reading request sizes out of a log.
258
+ case 'tools':
259
+ return tools({ ...global, json });
260
+
253
261
  // Undocumented alias for `mcp skill`, which is where it moved when `skills`
254
262
  // arrived. Anyone who learned the old spelling keeps it.
255
263
  case 'skill':
@@ -29,6 +29,14 @@ export interface PublishOutcome {
29
29
  readonly published?: string;
30
30
  /** Whether a running endpoint confirmed it is now serving the edit. */
31
31
  readonly served: boolean;
32
+ /**
33
+ * How many tools the endpoint advertises now, when it answered.
34
+ *
35
+ * Reported rather than inferred, for the same reason `served` is: the number
36
+ * that matters is the one the endpoint would hand a client, and only the
37
+ * endpoint knows it.
38
+ */
39
+ readonly tools?: number;
32
40
  /** The endpoint that was told, or would have been. */
33
41
  readonly url?: string;
34
42
  /** Why it is not being served yet, in a form fit to print. */
@@ -142,7 +150,11 @@ async function notifyReload(input: {
142
150
  return { served: false, url, reason: `the endpoint answered ${response.status}` };
143
151
  }
144
152
 
145
- const body = (await response.json()) as { reloaded?: unknown; reason?: unknown };
153
+ const body = (await response.json()) as {
154
+ reloaded?: unknown;
155
+ reason?: unknown;
156
+ tools?: unknown;
157
+ };
146
158
  if (body.reloaded !== true) {
147
159
  return {
148
160
  served: false,
@@ -154,7 +166,11 @@ async function notifyReload(input: {
154
166
  };
155
167
  }
156
168
 
157
- return { served: true, url };
169
+ return {
170
+ served: true,
171
+ url,
172
+ ...(typeof body.tools === 'number' ? { tools: body.tools } : {}),
173
+ };
158
174
  } catch {
159
175
  // Nothing listening, scaled to zero, or unreachable from here — all of
160
176
  // which resolve themselves the next time the endpoint starts, because the
@@ -175,7 +191,34 @@ function message(error: unknown): string {
175
191
  * is now: the endpoint either answered or it did not.
176
192
  */
177
193
  export function nextAfterEdit(outcome: PublishOutcome): string {
178
- if (outcome.served) return 'Serving it now — the endpoint has re-read its config.';
194
+ if (outcome.served) {
195
+ const served = 'Serving it now — the endpoint has re-read its config.';
196
+ if (outcome.tools === undefined) return served;
197
+
198
+ // The second half of the truth, and the half an operator is actually
199
+ // looking at. The endpoint re-reading its config is not the same event as
200
+ // the client in front of them learning about it: a client fetches
201
+ // `tools/list` when it connects and holds the answer, and this endpoint
202
+ // cannot tell it otherwise — it is stateless, so there is no stream on
203
+ // which to send `notifications/tools/list_changed`, and it no longer claims
204
+ // there is (ADR-032).
205
+ //
206
+ // So the tool count goes here, where the change happened, and so does the
207
+ // one action that picks it up. Without this line the command reports
208
+ // success and the operator watches a connector that never changes.
209
+ //
210
+ // Worded for either direction, because `policy deny` prints this too and a
211
+ // deny is the case this file already calls "the one kind of staleness worth
212
+ // being strict about". "Pick them up" was written for a `connect` and read
213
+ // as nonsense after a deny, where the client is holding one tool too many
214
+ // rather than one too few — and where the stale entry is a tool the model
215
+ // will keep calling until it is gone.
216
+ return (
217
+ `${served}\n` +
218
+ ` ${outcome.tools} tools are advertised now. A client connected before this is still\n` +
219
+ ` holding the list it fetched then — reconnect it to match.`
220
+ );
221
+ }
179
222
 
180
223
  // Naming the URL, because the likeliest reason nothing answered is that the
181
224
  // endpoint is somewhere else: `lanes link start --port` moves the socket
package/src/cli/usage.ts CHANGED
@@ -16,7 +16,7 @@ import { style } from './output.ts';
16
16
  /** How this CLI is invoked — the `link` area of the `lanes` command. */
17
17
  export const PROGRAM = 'lanes link';
18
18
 
19
- export const USAGE = `${style.bold(PROGRAM)} — a self-hosted MCP gateway for your accounts, memory, skills, and secrets
19
+ export const USAGE = `${style.bold(PROGRAM)} — a self-hostable MCP gateway for all your connections, memory, skills, and secrets
20
20
 
21
21
  ${style.bold('Everyday')}
22
22
  ${PROGRAM} setup plan [--json] what each provider needs, and which are connected
@@ -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