@lanes-sh/link 0.7.1 → 0.8.0
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 +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/brand.ts +39 -10
- package/src/cli/callback-page.ts +37 -104
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -75
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
package/src/server/endpoint.ts
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
import { MCP_PATH, serve } from './index.ts';
|
|
1
|
+
import { MCP_PATH, serve, type RunningServer } from './index.ts';
|
|
2
2
|
import { serveOverStdio } from './stdio.ts';
|
|
3
3
|
import { Generations, type OpenedWorkspace } from './generations.ts';
|
|
4
4
|
import type { AuthorizationSurface } from './oauth.ts';
|
|
5
5
|
import type { ProfileRuntime } from './mcp/index.ts';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
OidcAuthenticator,
|
|
12
|
-
OidcVerifier,
|
|
13
|
-
tokensMatch,
|
|
14
|
-
type Authenticator,
|
|
15
|
-
} from '#auth';
|
|
6
|
+
import { AuthenticatorChain } from '#auth';
|
|
7
|
+
import { openAuthorization } from './authorization.ts';
|
|
8
|
+
import { openReadListener } from './read/open.ts';
|
|
9
|
+
import { deployedReadDeps } from './read/deployed.ts';
|
|
10
|
+
import { version } from '#cli/version.ts';
|
|
16
11
|
import type { Logger } from '#connectivity';
|
|
17
12
|
import { silentLogger } from './logging.ts';
|
|
18
|
-
import { listProfiles } from '#profile';
|
|
13
|
+
import { listProfiles, readConnections } from '#profile';
|
|
19
14
|
import {
|
|
20
15
|
applyReconcile,
|
|
21
16
|
formatPlan,
|
|
@@ -82,6 +77,8 @@ export interface EndpointOptions {
|
|
|
82
77
|
export interface RunningEndpoint {
|
|
83
78
|
readonly url: string;
|
|
84
79
|
readonly profiles: readonly string[];
|
|
80
|
+
/** The dashboard read surface, when this workspace is paired (ADR-063). */
|
|
81
|
+
readonly readUrl?: string | undefined;
|
|
85
82
|
stop(): Promise<void>;
|
|
86
83
|
}
|
|
87
84
|
|
|
@@ -133,21 +130,33 @@ async function openReconciled(options: {
|
|
|
133
130
|
}
|
|
134
131
|
}
|
|
135
132
|
|
|
136
|
-
for
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
133
|
+
// Once for the workspace, over every connection the workspace holds.
|
|
134
|
+
//
|
|
135
|
+
// Runtime state is one store per workspace since contract 3, and reconcile
|
|
136
|
+
// disables everything in it that the connection list does not declare — so
|
|
137
|
+
// running it per profile over that profile's *grants* had each pass disable
|
|
138
|
+
// the connections only the other profiles granted. Two profiles was enough:
|
|
139
|
+
// the second pass disabled the first's accounts, every later call was
|
|
140
|
+
// refused `denied_connection_unauthorized`, and restarting flipped which
|
|
141
|
+
// profile survived.
|
|
142
|
+
//
|
|
143
|
+
// The primary's runtime is used for the stores because they are the same
|
|
144
|
+
// stores for every profile here. `workspaceConnections` is the whole list,
|
|
145
|
+
// which is what "undeclared" has to be measured against.
|
|
146
|
+
const declared = primary.workspaceConnections;
|
|
147
|
+
const result = await planReconcile(
|
|
148
|
+
declared,
|
|
149
|
+
primary.state,
|
|
150
|
+
primary.credentials,
|
|
151
|
+
primary.manifestFor,
|
|
152
|
+
);
|
|
153
|
+
if (!planIsNoop(result)) {
|
|
154
|
+
reporter.reconciled({
|
|
155
|
+
profile: primary.resolution.profile,
|
|
156
|
+
plan: formatPlan(result),
|
|
157
|
+
ofMany: false,
|
|
158
|
+
});
|
|
159
|
+
await applyReconcile(declared, primary.state, result);
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
return { primary, runtimes };
|
|
@@ -183,80 +192,6 @@ function closeAll(runtimes: ReadonlyMap<string, Runtime>): Promise<unknown> {
|
|
|
183
192
|
return Promise.all([...runtimes.values()].map((runtime) => runtime.close()));
|
|
184
193
|
}
|
|
185
194
|
|
|
186
|
-
/**
|
|
187
|
-
* The remote-client gate, if this profile declares one.
|
|
188
|
-
*
|
|
189
|
-
* Endpoint-scoped rather than per profile, like the bearer token and for the
|
|
190
|
-
* same reason (ADR-009): one URL serves every profile in the workspace, so
|
|
191
|
-
* there is one place a client authorises and one set of tokens.
|
|
192
|
-
*
|
|
193
|
-
* Returns null when `auth.authorization` is absent, and everything downstream
|
|
194
|
-
* treats null as "exactly as before" — no metadata published, no pointer on the
|
|
195
|
-
* `401`, one authenticator instead of a chain.
|
|
196
|
-
*/
|
|
197
|
-
async function openAuthorization(
|
|
198
|
-
primary: Runtime,
|
|
199
|
-
log: Logger,
|
|
200
|
-
): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
|
|
201
|
-
const declared = primary.config.auth.authorization;
|
|
202
|
-
if (!declared) return null;
|
|
203
|
-
|
|
204
|
-
const profile = primary.resolution.profile;
|
|
205
|
-
|
|
206
|
-
if (declared.mode === 'oidc') {
|
|
207
|
-
const audience = await primary.credentials.get(declared.client_id_ref);
|
|
208
|
-
if (!audience) {
|
|
209
|
-
// Refuse rather than verify without an audience. A verifier that cannot
|
|
210
|
-
// check who a token was issued for accepts every token the issuer minted
|
|
211
|
-
// for anything, which is the failure this mode exists to prevent.
|
|
212
|
-
throw new Error(
|
|
213
|
-
`auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
|
|
214
|
-
`this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const verifier = new OidcVerifier({
|
|
219
|
-
issuer: declared.issuer,
|
|
220
|
-
audience,
|
|
221
|
-
allowedSubjects: declared.allowed_subjects,
|
|
222
|
-
...(declared.introspection_endpoint
|
|
223
|
-
? { introspectionEndpoint: declared.introspection_endpoint }
|
|
224
|
-
: {}),
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
// The issuer is somebody else's origin, so it is a constant here rather
|
|
229
|
-
// than derived from the request.
|
|
230
|
-
surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
|
|
231
|
-
authenticator: new OidcAuthenticator(verifier, profile),
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const store = new OAuthStore(primary.state.kv);
|
|
236
|
-
const expected = primary.config.auth.token_ref;
|
|
237
|
-
|
|
238
|
-
const server = new OAuthServer({
|
|
239
|
-
store,
|
|
240
|
-
accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
|
|
241
|
-
// So a replayed refresh token leaves a line. It is refused rather than
|
|
242
|
-
// acted on (ADR-035), and a refusal nobody can see is how a connector
|
|
243
|
-
// losing its authorization came to need log forensics to explain.
|
|
244
|
-
log,
|
|
245
|
-
// Approval is proof of holding the endpoint token, compared the same way
|
|
246
|
-
// the request path compares it. There is one person behind this endpoint
|
|
247
|
-
// and they already have exactly one credential; a second one invented for
|
|
248
|
-
// the consent screen would be a password to lose.
|
|
249
|
-
verifyOwner: async (presented) => {
|
|
250
|
-
const token = await primary.credentials.get(expected);
|
|
251
|
-
return token !== null && tokensMatch(presented, token);
|
|
252
|
-
},
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
return {
|
|
256
|
-
surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
|
|
257
|
-
authenticator: new IssuedTokenAuthenticator(store, profile),
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
195
|
|
|
261
196
|
export async function startEndpoint(options: EndpointOptions): Promise<RunningEndpoint> {
|
|
262
197
|
const reporter = options.reporter ?? SILENT;
|
|
@@ -278,13 +213,26 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
278
213
|
if (!token) {
|
|
279
214
|
throw new Error(
|
|
280
215
|
`No profile token at "${primary.config.auth.token_ref}" in this target's credential store. ` +
|
|
281
|
-
'A deployed instance never mints its own — run `lanes link token rotate --
|
|
216
|
+
'A deployed instance never mints its own — run `lanes link token rotate --workspace <name>` ' +
|
|
282
217
|
'from your machine, or `lanes link secrets push --from local --to cloud`, then redeploy.',
|
|
283
218
|
);
|
|
284
219
|
}
|
|
285
220
|
}
|
|
286
221
|
|
|
287
|
-
|
|
222
|
+
// Read through a holder rather than closed over `runtimes`, because a
|
|
223
|
+
// reload replaces that map and the gate is deliberately built once
|
|
224
|
+
// (ADR-029). Without the indirection, a member added after start would
|
|
225
|
+
// stay invisible until the endpoint was restarted — which is precisely the
|
|
226
|
+
// thing `profile members add` tells the operator has taken effect.
|
|
227
|
+
let serving: ReadonlyMap<string, Runtime> = runtimes;
|
|
228
|
+
|
|
229
|
+
const gate = await openAuthorization(primary, log, async (subject) =>
|
|
230
|
+
[...serving]
|
|
231
|
+
.filter(([, runtime]) =>
|
|
232
|
+
runtime.config.members.some((member) => member.subject === subject),
|
|
233
|
+
)
|
|
234
|
+
.map(([name]) => name),
|
|
235
|
+
);
|
|
288
236
|
|
|
289
237
|
// The authenticator and the authorization gate are built once, from the
|
|
290
238
|
// runtime this endpoint booted with, and are deliberately not part of what
|
|
@@ -295,6 +243,7 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
295
243
|
{ profiles: profileRuntimes(runtimes), close: () => closeAll(runtimes).then(() => {}) },
|
|
296
244
|
async (): Promise<OpenedWorkspace> => {
|
|
297
245
|
const reopened = await openReconciled(options);
|
|
246
|
+
serving = reopened.runtimes;
|
|
298
247
|
return {
|
|
299
248
|
profiles: profileRuntimes(reopened.runtimes),
|
|
300
249
|
close: () => closeAll(reopened.runtimes).then(() => {}),
|
|
@@ -312,6 +261,12 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
312
261
|
{ primary: primary.resolution.profile, log, ...(gate ? { remoteClients: true } : {}) },
|
|
313
262
|
);
|
|
314
263
|
|
|
264
|
+
// Read once. `version()` walks up to the install root and parses
|
|
265
|
+
// `package.json`; doing it per request would put a synchronous file read on
|
|
266
|
+
// the read surface's hot path to answer a value that cannot change while
|
|
267
|
+
// this process lives.
|
|
268
|
+
const runningVersion = version();
|
|
269
|
+
|
|
315
270
|
const server = serve({
|
|
316
271
|
generations,
|
|
317
272
|
primary: primary.resolution.profile,
|
|
@@ -322,20 +277,46 @@ export async function startEndpoint(options: EndpointOptions): Promise<RunningEn
|
|
|
322
277
|
...(gate ? { authorization: gate.surface } : {}),
|
|
323
278
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
324
279
|
...(options.host !== undefined ? { host: options.host } : {}),
|
|
280
|
+
// Offered unconditionally and discarded by `serve()` on a loopback bind,
|
|
281
|
+
// which is where every other property of the bind address is decided. It
|
|
282
|
+
// opens nothing and reads no credential, so building it for a bind that
|
|
283
|
+
// will not use it costs a closure (ADR-064).
|
|
284
|
+
read: deployedReadDeps({
|
|
285
|
+
primary,
|
|
286
|
+
profiles: () => generations.current.profiles,
|
|
287
|
+
log,
|
|
288
|
+
version: runningVersion,
|
|
289
|
+
}),
|
|
325
290
|
});
|
|
326
291
|
|
|
327
292
|
// After `serve()`, so the record means the socket is bound. Recording it
|
|
328
293
|
// from the constructor claimed an endpoint that a failed bind never served.
|
|
329
294
|
generations.announce();
|
|
330
295
|
|
|
296
|
+
// Only when `lanes link pair` has provisioned all three. Absent, this is
|
|
297
|
+
// simply not served — the read surface is opt-in and its absence is the
|
|
298
|
+
// default (ADR-063), so an endpoint that was never paired binds one port
|
|
299
|
+
// exactly as it always did.
|
|
300
|
+
const read = await openReadListener(
|
|
301
|
+
primary,
|
|
302
|
+
server,
|
|
303
|
+
() => generations.current.profiles,
|
|
304
|
+
log,
|
|
305
|
+
runningVersion,
|
|
306
|
+
);
|
|
307
|
+
|
|
331
308
|
return {
|
|
332
309
|
url: server.url,
|
|
333
310
|
profiles: [...runtimes.keys()],
|
|
311
|
+
...(read ? { readUrl: read.url } : {}),
|
|
334
312
|
// `server.stop()` closes the request handler, which closes whichever
|
|
335
313
|
// generation is current — and a generation owns the runtimes it opened.
|
|
336
314
|
// Closing `runtimes` here too would reach past a reload and close a set
|
|
337
315
|
// nothing is serving from any more.
|
|
338
|
-
stop: () =>
|
|
316
|
+
stop: async () => {
|
|
317
|
+
await read?.stop();
|
|
318
|
+
await server.stop();
|
|
319
|
+
},
|
|
339
320
|
};
|
|
340
321
|
} catch (error) {
|
|
341
322
|
await closeAll(runtimes);
|
package/src/server/generation.ts
CHANGED
|
@@ -189,7 +189,16 @@ export class Generation {
|
|
|
189
189
|
* a handler outliving its generation is the stale-config bug.
|
|
190
190
|
*/
|
|
191
191
|
handlerFor(principal: Principal, clientLabel: string | undefined): McpHttpHandler {
|
|
192
|
-
|
|
192
|
+
// The delegation list is part of the key, not just the identity.
|
|
193
|
+
//
|
|
194
|
+
// `Principal` gained `profiles` this release, and `mergeCapabilities` and
|
|
195
|
+
// `forProfile` both read it off the *captured* principal — so two tokens for
|
|
196
|
+
// one subject with different scopes hashed to one entry and whichever
|
|
197
|
+
// authorized first decided what the other could reach. Removing somebody
|
|
198
|
+
// from a profile and re-authorizing then served them the profile they had
|
|
199
|
+
// just lost, or refused one they still had, depending on order.
|
|
200
|
+
const reach = principal.profiles === undefined ? '*' : [...principal.profiles].sort().join(',');
|
|
201
|
+
const key = `${principal.id}\u0000${reach}\u0000${clientLabel ?? ''}`;
|
|
193
202
|
const existing = this.#handlers.get(key);
|
|
194
203
|
if (existing) return existing;
|
|
195
204
|
|
package/src/server/harness.ts
CHANGED
|
@@ -5,10 +5,11 @@ import {
|
|
|
5
5
|
IssuedTokenAuthenticator,
|
|
6
6
|
OAuthServer,
|
|
7
7
|
OAuthStore,
|
|
8
|
-
|
|
8
|
+
type Federation,
|
|
9
9
|
} from '#auth';
|
|
10
10
|
import { oneProfile, type ProfileRuntime } from './mcp/index.ts';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
type ConnectionConfig, parseConfig, type Config } from '#profile';
|
|
12
13
|
import { ProviderRegistry, toPolicyDocument } from '#registry';
|
|
13
14
|
import { Dispatcher } from '#dispatch';
|
|
14
15
|
import { createMemoryCredentials, createMemoryState } from '#stores/state/testing.ts';
|
|
@@ -33,26 +34,58 @@ import { serveOverStdio } from './stdio.ts';
|
|
|
33
34
|
* the right calls, and a mocked transport cannot demonstrate that.
|
|
34
35
|
*/
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The connections a harness config implies, derived from its grants.
|
|
39
|
+
*
|
|
40
|
+
* A test config declares grants and no `connections.yaml` — there is no
|
|
41
|
+
* workspace on disk to read one from. Deriving the rows from the grant refs
|
|
42
|
+
* keeps the harness honest about the only thing dispatch uses them for, which is
|
|
43
|
+
* resolving `<provider>.<id>` to a provider and an id. Anything richer (an
|
|
44
|
+
* account label, a credential ref) belongs to a real workspace and a test that
|
|
45
|
+
* needs one builds it.
|
|
46
|
+
*/
|
|
47
|
+
function harnessConnections(config: Config): ConnectionConfig[] {
|
|
48
|
+
return config.grants.map((grant) => {
|
|
49
|
+
const [provider = '', id = ''] = grant.connection.split('.');
|
|
50
|
+
return { provider, id, account: grant.connection };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
export const TEST_TOKEN = 'llk_test_token_value';
|
|
37
55
|
|
|
56
|
+
/** A signed-in person no profile lists. See the federation stub below. */
|
|
57
|
+
export const STRANGER = 'NOBODY_LISTS_THIS_PERSON';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A profile for the harness, from the `allow`/`deny` a test hands over.
|
|
61
|
+
*
|
|
62
|
+
* The two `example` accounts are the point: every test here is about a rule
|
|
63
|
+
* covering both, or one of them, so the harness gives each its own grant row
|
|
64
|
+
* carrying the same rules (ADR-058). That is what the flat block used to mean,
|
|
65
|
+
* which keeps every existing test asserting what it was written to assert.
|
|
66
|
+
*/
|
|
38
67
|
export function configFor(profile: string, port: number, policy: string): Config {
|
|
68
|
+
const rules = policy
|
|
69
|
+
.split('\n')
|
|
70
|
+
.filter((line) => line.trim().length > 0)
|
|
71
|
+
.map((line) => ` ${line.trim()}`)
|
|
72
|
+
.join('\n');
|
|
73
|
+
|
|
74
|
+
const grant = (id: string): string =>
|
|
75
|
+
` - connection: example.${id}\n${rules.replace(/^ {4}(allow|deny):/gm, ' $1:')}`;
|
|
76
|
+
|
|
39
77
|
return parseConfig(`
|
|
40
|
-
contract:
|
|
78
|
+
contract: 3
|
|
41
79
|
instance:
|
|
42
80
|
profile: ${profile}
|
|
43
81
|
port: ${port}
|
|
44
82
|
limits:
|
|
45
83
|
requests_per_minute: 1000
|
|
46
84
|
upstream_calls_per_minute: 1000
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- id: b
|
|
52
|
-
provider: example
|
|
53
|
-
account: Scratch B
|
|
54
|
-
policy:
|
|
55
|
-
${policy}
|
|
85
|
+
grants:
|
|
86
|
+
${grant('a')}
|
|
87
|
+
${grant('b')}
|
|
88
|
+
members: []
|
|
56
89
|
`).config;
|
|
57
90
|
}
|
|
58
91
|
|
|
@@ -110,6 +143,15 @@ export interface HarnessOptions {
|
|
|
110
143
|
refreshSkills?: (registry: ProviderRegistry) => Promise<void>;
|
|
111
144
|
/** Serve the `self` authorization flow alongside the bearer token. */
|
|
112
145
|
authorization?: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* The identity half of that flow, stubbed.
|
|
148
|
+
*
|
|
149
|
+
* The real one talks to lanes.sh and verifies a signature; a test that
|
|
150
|
+
* exercised it would be testing `AssertionVerifier`, which has its own file
|
|
151
|
+
* and its own key pair. What a harness test is about is what the endpoint
|
|
152
|
+
* does *with* an answer, so the answer is injected.
|
|
153
|
+
*/
|
|
154
|
+
federation?: Partial<Federation>;
|
|
113
155
|
/**
|
|
114
156
|
* What a reload re-reads, standing in for `openReconciled` over a workspace
|
|
115
157
|
* this harness does not have. Throwing is how the "a failed reload keeps
|
|
@@ -162,6 +204,8 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
162
204
|
|
|
163
205
|
const dispatcher = new Dispatcher({
|
|
164
206
|
config,
|
|
207
|
+
connections: harnessConnections(config),
|
|
208
|
+
oauthApps: [],
|
|
165
209
|
registry,
|
|
166
210
|
connectorFor: (providerId): AnyConnector | undefined => {
|
|
167
211
|
const entry = registry.get(providerId);
|
|
@@ -199,6 +243,8 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
199
243
|
policy: extraPolicy,
|
|
200
244
|
dispatcher: new Dispatcher({
|
|
201
245
|
config: extraConfig,
|
|
246
|
+
connections: harnessConnections(extraConfig),
|
|
247
|
+
oauthApps: [],
|
|
202
248
|
registry,
|
|
203
249
|
connectorFor: (providerId): AnyConnector | undefined => {
|
|
204
250
|
const entry = registry.get(providerId);
|
|
@@ -241,7 +287,19 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
241
287
|
accessTokenTtlMs: 3_600_000,
|
|
242
288
|
log,
|
|
243
289
|
...(options.now ? { now: options.now } : {}),
|
|
244
|
-
|
|
290
|
+
federation: {
|
|
291
|
+
consentUrl: 'https://lanes.example/link/authorize',
|
|
292
|
+
// Anything non-empty verifies, as the subject it spells. Enough to
|
|
293
|
+
// drive the flow, and obviously not a verifier.
|
|
294
|
+
verify: async (assertion) =>
|
|
295
|
+
assertion ? { subject: `lanes:${assertion}`, email: null } : null,
|
|
296
|
+
// One reserved spelling answers "no profile names them", because
|
|
297
|
+
// that refusal is a real branch — a person signs in successfully
|
|
298
|
+
// and still reaches nothing — and there has to be a way to drive it.
|
|
299
|
+
profilesFor: async (subject) =>
|
|
300
|
+
subject === `lanes:${STRANGER}` ? [] : [options.profile],
|
|
301
|
+
...options.federation,
|
|
302
|
+
},
|
|
245
303
|
}),
|
|
246
304
|
issuer: (origin: string) => origin,
|
|
247
305
|
mcpPath: '/mcp',
|
package/src/server/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { capabilityIdForToolName } from '#server/mcp';
|
|
|
4
4
|
import { ATTACHMENTS_PATH, handleAttachments } from './attachments.ts';
|
|
5
5
|
import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
|
|
6
6
|
import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
|
|
7
|
+
import { isReadPath, readRoutes, type ReadDeps } from './read/routes.ts';
|
|
7
8
|
import type { Generation } from './generation.ts';
|
|
8
9
|
import type { Generations } from './generations.ts';
|
|
9
10
|
import {
|
|
@@ -67,6 +68,16 @@ export interface ServerOptions {
|
|
|
67
68
|
* — a page the owner happens to be visiting — before this would be reached.
|
|
68
69
|
*/
|
|
69
70
|
readonly meterUnauthenticated?: boolean | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The dashboard's read surface, when this bind may serve it (ADR-064).
|
|
73
|
+
*
|
|
74
|
+
* Another property of the bind address, decided in the same lines of
|
|
75
|
+
* `serve()` as `cors` and the meter. Absent on loopback, where the TLS
|
|
76
|
+
* listener in `./read/open.ts` serves it on its own port instead — a
|
|
77
|
+
* cross-origin grant on `127.0.0.1` is what `./rebinding.ts` refuses
|
|
78
|
+
* outright, and ADR-039's rule is not being relaxed to fit this in.
|
|
79
|
+
*/
|
|
80
|
+
readonly read?: ReadDeps | undefined;
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
export const MCP_PATH = '/mcp';
|
|
@@ -155,6 +166,8 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
155
166
|
healthPath: HEALTH_PATH,
|
|
156
167
|
isAuthorizationPath,
|
|
157
168
|
authorizationEnabled: options.authorization !== undefined,
|
|
169
|
+
isReadPath,
|
|
170
|
+
readEnabled: options.read !== undefined,
|
|
158
171
|
});
|
|
159
172
|
if (refusal) {
|
|
160
173
|
options.log.warn('rejected request', { reason: 'unauthenticated_rate' });
|
|
@@ -188,6 +201,17 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
188
201
|
});
|
|
189
202
|
}
|
|
190
203
|
|
|
204
|
+
// Above the 404 gate because these are deliberately not in the three-path
|
|
205
|
+
// set, and never through `options.authenticator`: the pairing token is a
|
|
206
|
+
// different credential for a different surface, and one shared check
|
|
207
|
+
// would make each able to do the other's job (ADR-063). Below the meter,
|
|
208
|
+
// because verifying one costs a credential-store read. Only what
|
|
209
|
+
// `isReadPath` matched is handed over — `readRoutes` answers everything
|
|
210
|
+
// it is given, so a wider hand-off would swallow `/mcp`.
|
|
211
|
+
if (options.read && isReadPath(url.pathname)) {
|
|
212
|
+
return await readRoutes(request, options.read);
|
|
213
|
+
}
|
|
214
|
+
|
|
191
215
|
if (
|
|
192
216
|
url.pathname !== MCP_PATH &&
|
|
193
217
|
url.pathname !== ATTACHMENTS_PATH &&
|
|
@@ -344,8 +368,15 @@ export function serve(options: ServeOptions): RunningServer {
|
|
|
344
368
|
const cors: CorsPolicy | undefined = loopback
|
|
345
369
|
? undefined
|
|
346
370
|
: { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
|
|
371
|
+
// The fourth property of this bind address, decided with the other three.
|
|
372
|
+
// Never on loopback: `./read/open.ts` serves it there over TLS on its own
|
|
373
|
+
// port, and a deployment-only grant that leaked onto `127.0.0.1` is exactly
|
|
374
|
+
// what ADR-039 refuses. Discarded rather than overridable, as `cors` is.
|
|
375
|
+
const read = loopback ? undefined : options.read;
|
|
376
|
+
|
|
347
377
|
const handler = createRequestHandler({
|
|
348
378
|
...options,
|
|
379
|
+
...(read ? { read } : { read: undefined }),
|
|
349
380
|
// Off on loopback for the same reason `cors` is undefined there, and decided
|
|
350
381
|
// here so every property of the bind address is decided together. An
|
|
351
382
|
// explicit `true` wins, which is how a test drives the deployed behaviour.
|
package/src/server/mcp/build.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { isPrompt, isResource, isTool } from '#connectivity';
|
|
3
3
|
import { SERVER_ICONS } from './icon.ts';
|
|
4
|
+
import { GUIDE_TITLE, GUIDE_URI, guideDocument } from './guide.ts';
|
|
4
5
|
import { serverInstructions } from './instructions.ts';
|
|
5
6
|
import { SERVER_NAME } from './naming.ts';
|
|
6
7
|
import { registerPrompt } from './prompts.ts';
|
|
@@ -77,12 +78,30 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
|
|
|
77
78
|
// server — to be told nothing is there.
|
|
78
79
|
capabilities: {
|
|
79
80
|
tools: { listChanged: false },
|
|
80
|
-
|
|
81
|
+
// Unconditional now: `lanes://instructions` is registered below
|
|
82
|
+
// whatever policy said, so this endpoint always has at least one
|
|
83
|
+
// resource and gating the capability on the merged set would advertise
|
|
84
|
+
// nothing while serving something.
|
|
85
|
+
resources: { listChanged: false },
|
|
81
86
|
...(offers(merged, isPrompt) ? { prompts: { listChanged: false } } : {}),
|
|
82
87
|
},
|
|
83
88
|
},
|
|
84
89
|
);
|
|
85
90
|
|
|
91
|
+
// Always, and ahead of everything policy decided. This describes the surface
|
|
92
|
+
// rather than exposing any of it, so there is nothing here to grant — and a
|
|
93
|
+
// client whose owner has connected nothing at all still gets an account of
|
|
94
|
+
// what the thing is. It also means `resources` is advertised unconditionally,
|
|
95
|
+
// which `offers` below no longer decides on its own.
|
|
96
|
+
server.registerResource(
|
|
97
|
+
'instructions',
|
|
98
|
+
GUIDE_URI,
|
|
99
|
+
{ title: GUIDE_TITLE, description: 'What this endpoint is and how to behave against it', mimeType: 'text/markdown' },
|
|
100
|
+
async (uri: URL) => ({
|
|
101
|
+
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: guideDocument() }],
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
86
105
|
for (const [id, entry] of merged) {
|
|
87
106
|
// Discovered first: an upstream MCP server or an OpenAPI document supplies
|
|
88
107
|
// the schema, and there is no local capability object to inspect.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which agent is calling, read off the request rather than off a header.
|
|
3
|
+
*
|
|
4
|
+
* The audit log's `clientLabel` field has always said it holds "the MCP
|
|
5
|
+
* `clientInfo` name". It did not. Over HTTP it read an `x-mcp-client` header,
|
|
6
|
+
* which is not part of MCP and which no client sends; over a pipe nothing set
|
|
7
|
+
* it at all. So the one field that exists to say *who made this call* was empty
|
|
8
|
+
* on every event this endpoint has ever written.
|
|
9
|
+
*
|
|
10
|
+
* The protocol does carry it. A client announces itself at `initialize` and the
|
|
11
|
+
* SDK repeats that announcement in every later request, in `_meta` under
|
|
12
|
+
* `io.modelcontextprotocol/clientInfo`, which the server surfaces on the
|
|
13
|
+
* request envelope. Reading it there rather than from the handshake is what
|
|
14
|
+
* makes it work at all here: streamable HTTP is stateless on this endpoint, a
|
|
15
|
+
* fresh `McpServer` is built and discarded per request (`build.ts`), and a
|
|
16
|
+
* handshake captured on one instance is gone before the next arrives.
|
|
17
|
+
*
|
|
18
|
+
* **A client that announces itself only at `initialize` and never repeats it is
|
|
19
|
+
* still anonymous**, and that is the honest outcome rather than a gap worth
|
|
20
|
+
* papering over. The SDK does not backfill the envelope from the session, so
|
|
21
|
+
* inferring one would mean this endpoint keeping its own session table to hold
|
|
22
|
+
* a field it is not allowed to trust anyway.
|
|
23
|
+
*
|
|
24
|
+
* **Self-reported, and labelled as such wherever it surfaces.** A client may
|
|
25
|
+
* call itself anything. It is recorded so a reader can see which agent made a
|
|
26
|
+
* call and is never consulted to decide what that agent may do — the same rule
|
|
27
|
+
* the field carried when it was a header, and the reason widening it is safe.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Where the SDK puts the client's own `Implementation` on each request. */
|
|
31
|
+
const CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The name the client gave for itself, or undefined.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately tolerant. This is untrusted input on a path whose failure mode
|
|
37
|
+
* would otherwise be an exception inside a tool call that was going to succeed,
|
|
38
|
+
* and the worst honest outcome is the empty field that already exists.
|
|
39
|
+
*/
|
|
40
|
+
export function clientLabelFrom(extra: unknown): string | undefined {
|
|
41
|
+
if (typeof extra !== 'object' || extra === null) return undefined;
|
|
42
|
+
|
|
43
|
+
const request = (extra as { mcpReq?: unknown }).mcpReq;
|
|
44
|
+
if (typeof request !== 'object' || request === null) return undefined;
|
|
45
|
+
|
|
46
|
+
const envelope = (request as { envelope?: unknown }).envelope;
|
|
47
|
+
if (typeof envelope !== 'object' || envelope === null) return undefined;
|
|
48
|
+
|
|
49
|
+
const info = (envelope as Record<string, unknown>)[CLIENT_INFO_META_KEY];
|
|
50
|
+
if (typeof info !== 'object' || info === null) return undefined;
|
|
51
|
+
|
|
52
|
+
const name = (info as { name?: unknown }).name;
|
|
53
|
+
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
54
|
+
}
|