@lanes-sh/link 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/instructions/skills/lanes-link/SKILL.md +1 -1
- package/package.json +3 -3
- package/src/cli/commands/operate/outputs.ts +5 -38
- package/src/cli/commands/operate/tools.ts +343 -0
- package/src/cli/commands/operate.ts +1 -0
- package/src/cli/endpoint-url.ts +46 -1
- package/src/cli/lanes.ts +1 -1
- package/src/cli/main.ts +8 -0
- package/src/cli/publish.ts +46 -3
- package/src/cli/usage.ts +2 -1
- package/src/deployments/deploy.ts +28 -1
- package/src/server/endpoint.ts +11 -1
- package/src/server/generation.ts +217 -0
- package/src/server/generations.ts +71 -213
- package/src/server/harness.ts +5 -0
- package/src/server/index.ts +2 -1
- package/src/server/mcp/build.ts +56 -2
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/visibility.ts +22 -0
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# Lanes Link
|
|
2
2
|
|
|
3
|
-
**One secure endpoint between your AI agents and your
|
|
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-
|
|
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,
|
|
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-
|
|
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.
|
|
4
|
-
"description": "A self-
|
|
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://
|
|
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,
|
|
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/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-
|
|
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':
|
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
|
@@ -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-
|
|
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
|