@lanes-sh/link 0.7.2 → 0.9.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 +148 -73
- 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 +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- 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 +90 -88
- 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 +16 -6
- 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 +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -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 +33 -13
- 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 +53 -5
- 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/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- 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-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -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 +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- 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 +34 -13
- package/src/deployments/upload.ts +60 -27
- 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 +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- 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 +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -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 +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- 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/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { RESERVED_PROVIDER_IDS } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The long form of what this endpoint is, served as a resource.
|
|
5
|
+
*
|
|
6
|
+
* `initialize.instructions` has a budget it pays on every request forever, so it
|
|
7
|
+
* carries only the habits that must arrive everywhere. This is the document a
|
|
8
|
+
* client reads once when it wants the whole account, and it is served always,
|
|
9
|
+
* without a tool grant, because it describes the surface rather than exposing
|
|
10
|
+
* any of it.
|
|
11
|
+
*
|
|
12
|
+
* **Why a resource rather than a longer `instructions`.** A client caches
|
|
13
|
+
* `initialize` for the life of its registration, so guidance improved after
|
|
14
|
+
* somebody connected never reaches them. A resource is fetched when it is read,
|
|
15
|
+
* so this can be corrected without anybody reconnecting.
|
|
16
|
+
*
|
|
17
|
+
* **No vendor may be named here.** `src/architecture.test.ts` forbids it
|
|
18
|
+
* anywhere under `server/`, and rightly: what an owner has connected is theirs,
|
|
19
|
+
* and prose naming one provider would be wrong for everybody else.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const GUIDE_URI = 'lanes://instructions';
|
|
23
|
+
|
|
24
|
+
export const GUIDE_TITLE = 'How this endpoint works';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The document.
|
|
28
|
+
*
|
|
29
|
+
* Fixed prose, deliberately: it describes the model rather than this workspace,
|
|
30
|
+
* and the per-principal facts are in `initialize.instructions`, which is
|
|
31
|
+
* computed. Two documents with different lifetimes, and mixing them would make
|
|
32
|
+
* this one wrong the moment a connection was added.
|
|
33
|
+
*/
|
|
34
|
+
export function guideDocument(): string {
|
|
35
|
+
return `# Lanes Link
|
|
36
|
+
|
|
37
|
+
One endpoint in front of everything its owner has chosen to expose. It
|
|
38
|
+
authenticates, applies permissions per call, and records what happened.
|
|
39
|
+
|
|
40
|
+
## The three words
|
|
41
|
+
|
|
42
|
+
**Connection.** One authorised account or store, named \`<provider>.<id>\`. It
|
|
43
|
+
belongs to the workspace, so the same account can be reached from more than one
|
|
44
|
+
profile without being authorised twice.
|
|
45
|
+
|
|
46
|
+
**Profile.** A selection of connections, with the capabilities allowed on each,
|
|
47
|
+
and the people who may consume it. This is how someone keeps work and personal
|
|
48
|
+
apart, and how they hand you one mailbox read-only while another is writable.
|
|
49
|
+
|
|
50
|
+
**Workspace.** Where connections and profiles live, and which credential store
|
|
51
|
+
opens them. You never name one; the endpoint you are talking to is already in it.
|
|
52
|
+
|
|
53
|
+
## Routing
|
|
54
|
+
|
|
55
|
+
Every tool takes \`profile\` and \`connection\`. Both are enums, and both are
|
|
56
|
+
already narrowed to what you may reach, so anything offered is something you are
|
|
57
|
+
allowed to use.
|
|
58
|
+
|
|
59
|
+
**When it is ambiguous which profile is meant, ask.** Do not default to whichever
|
|
60
|
+
is listed first. A profile is a boundary somebody drew on purpose, and crossing
|
|
61
|
+
it is the failure this design exists to prevent.
|
|
62
|
+
|
|
63
|
+
A connection belongs to a profile. Naming one from a different profile is
|
|
64
|
+
refused, and the refusal lists what is available where you asked.
|
|
65
|
+
|
|
66
|
+
## The owner's own material
|
|
67
|
+
|
|
68
|
+
${RESERVED_PROVIDER_IDS.join(', ')} are not third-party services. They are the
|
|
69
|
+
owner's own stores, and each is a connection like any other, so a profile decides
|
|
70
|
+
which instances it can reach and what it may do with them.
|
|
71
|
+
|
|
72
|
+
**Memory and tasks are different stores.** Search memory before concluding you do
|
|
73
|
+
not know something about this person or their work. A thing to *do* is a task,
|
|
74
|
+
and it has a status; "remember to..." is a task, not a memory. Both are served
|
|
75
|
+
back to every later session, so write when asked rather than by habit.
|
|
76
|
+
|
|
77
|
+
**Skills are the owner's procedures**, surfaced as prompts rather than tools.
|
|
78
|
+
That is deliberate: a procedure is selected by the person, not chosen by the
|
|
79
|
+
model, and you cannot read one's body. If a task has a skill for it, say so and
|
|
80
|
+
let them invoke it rather than improvising your own version.
|
|
81
|
+
|
|
82
|
+
**Vault values are credentials.** Use one to do the thing that needs it. Do not
|
|
83
|
+
quote it back, summarise it, or write it anywhere.
|
|
84
|
+
|
|
85
|
+
**Entities are the people, companies and projects this owner deals with.** Before
|
|
86
|
+
using anyone's address or handle, look them up. A lookup returns every match and
|
|
87
|
+
never chooses: more than one means ask which is meant, not take the first.
|
|
88
|
+
|
|
89
|
+
## What is set up is answerable
|
|
90
|
+
|
|
91
|
+
Before saying something cannot be reached, or that an account must be added, ask
|
|
92
|
+
the setup surface. It reports what exists and what is missing, and it gives the
|
|
93
|
+
exact command for the missing thing.
|
|
94
|
+
|
|
95
|
+
**Running that command is the owner's to do, and inventing one is not.** Every
|
|
96
|
+
change to what exists here is a command a person runs; nothing on this surface
|
|
97
|
+
adds a connection, edits a profile, or changes who may consume it. That is not an
|
|
98
|
+
omission to work around.
|
|
99
|
+
|
|
100
|
+
## When a call is refused
|
|
101
|
+
|
|
102
|
+
A refusal is information, not an obstacle. Three kinds, and they mean different
|
|
103
|
+
things:
|
|
104
|
+
|
|
105
|
+
- **The profile is not available.** You are not a member of it. Nothing you can
|
|
106
|
+
do; its owner adds you.
|
|
107
|
+
- **The connection is not part of this profile.** Look at what is, in the enum.
|
|
108
|
+
- **The capability is denied.** The profile allows a narrower set than the tool
|
|
109
|
+
suggests. Say what was refused and let the owner decide whether to widen it.
|
|
110
|
+
|
|
111
|
+
Retrying a refusal unchanged produces the same refusal and one more line in the
|
|
112
|
+
owner's audit log.
|
|
113
|
+
|
|
114
|
+
## Files are named, not carried
|
|
115
|
+
|
|
116
|
+
Where a tool takes attachments, give a path, an HTTPS URL, or an attachment
|
|
117
|
+
already on another message. Do not base64 a file into an argument: it is recorded
|
|
118
|
+
in the audit log, and the log is something the owner reads.
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
@@ -53,7 +53,7 @@ import type { MergedCapability } from './visibility.ts';
|
|
|
53
53
|
*/
|
|
54
54
|
const OPENING = `This endpoint is one place to reach what its owner has chosen to expose. It
|
|
55
55
|
authenticates, applies permissions, and records what happened, so you do not
|
|
56
|
-
have to.`;
|
|
56
|
+
have to. Read \`lanes://instructions\` for the whole account of how it works.`;
|
|
57
57
|
|
|
58
58
|
const ROUTING = `**Routing.** Every tool takes \`profile\` and \`connection\`. A profile is how
|
|
59
59
|
someone separates work from personal — when it is ambiguous which one is meant,
|
|
@@ -102,12 +102,12 @@ quote it back, summarise it, or write it anywhere.`;
|
|
|
102
102
|
* Asked to connect a second mailbox, a client with no setup surface and no
|
|
103
103
|
* skill answered that it could not and then invented the procedure — edit the
|
|
104
104
|
* profile YAML, run "the auth command" — neither of which is a thing. It had
|
|
105
|
-
* no way to know `
|
|
105
|
+
* no way to know `lanes_setup_overview` answers exactly that, so the instruction has
|
|
106
106
|
* to arrive here: this is the only channel that reaches a client which has
|
|
107
107
|
* merely been pointed at the URL.
|
|
108
108
|
*/
|
|
109
109
|
const SETUP = `**What is set up is answerable.** Before saying something cannot be reached, or
|
|
110
|
-
that an account must be added, call \`
|
|
110
|
+
that an account must be added, call \`lanes_setup_overview\` — then \`lanes_setup_provider\`
|
|
111
111
|
for the exact command. Running it is the owner's to do; inventing it is not.`;
|
|
112
112
|
|
|
113
113
|
/**
|
|
@@ -117,14 +117,14 @@ for the exact command. Running it is the owner's to do; inventing it is not.`;
|
|
|
117
117
|
* declaration would put a per-profile list into a string with a fixed ceiling —
|
|
118
118
|
* so the workspace with the most identities to keep straight is exactly the one
|
|
119
119
|
* whose list would be summarised away first. A pointer costs the same for one
|
|
120
|
-
* profile as for twenty, and `
|
|
120
|
+
* profile as for twenty, and `lanes_identity_list` has room to say when each applies,
|
|
121
121
|
* which is the half that actually prevents the mistake.
|
|
122
122
|
*
|
|
123
123
|
* Conditional like the rest: a profile that declares nothing has no `identity`
|
|
124
124
|
* connection, so the capability is unreachable and this paragraph is unspent.
|
|
125
125
|
*/
|
|
126
126
|
const IDENTITY = `**Identity is declared, not inferred.** Where a name, address or handle of the
|
|
127
|
-
owner's is needed, call \`
|
|
127
|
+
owner's is needed, call \`lanes_identity_list\`: a profile may hold several, each with a
|
|
128
128
|
note on when it applies.`;
|
|
129
129
|
|
|
130
130
|
/**
|
|
@@ -136,7 +136,7 @@ note on when it applies.`;
|
|
|
136
136
|
* message sent to the wrong Jan has left.
|
|
137
137
|
*
|
|
138
138
|
* It carries two rules rather than one, because dropping the refusal made the
|
|
139
|
-
* second necessary. `
|
|
139
|
+
* second necessary. `lanes_entities_find` returns every match and sets no error, so a
|
|
140
140
|
* client is not stopped by anything: nothing but this sentence stands between
|
|
141
141
|
* "two candidates" and an agent using the first. The count and the wording of
|
|
142
142
|
* the tool result say it too, and this says it before the first call rather
|
|
@@ -166,7 +166,7 @@ which is meant, not take the first.`;
|
|
|
166
166
|
* form and this one arrives only once the owner has declared themselves.
|
|
167
167
|
*/
|
|
168
168
|
const IDENTITY_AND_ENTITIES = `**Who someone is, is declared rather than inferred.** For the owner's own name,
|
|
169
|
-
address or handle, call \`
|
|
169
|
+
address or handle, call \`lanes_identity_list\`. For anyone else — a person, a company,
|
|
170
170
|
a project — call \`entities_find\`, which returns every match and never chooses:
|
|
171
171
|
more than one means ask which is meant, not take the first.`;
|
|
172
172
|
|
|
@@ -201,14 +201,14 @@ not land, do not redo what already succeeded, and offer to retry.`;
|
|
|
201
201
|
|
|
202
202
|
/** Which paragraph each owner-layer provider brings, when it is reachable alone. */
|
|
203
203
|
const OWNER_HABITS: Record<string, string> = {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
204
|
+
lanes_memory: MEMORY,
|
|
205
|
+
lanes_tasks: TASKS,
|
|
206
|
+
lanes_assets: ASSETS,
|
|
207
|
+
lanes_skills: SKILLS,
|
|
208
|
+
lanes_vault: VAULT,
|
|
209
|
+
lanes_setup: SETUP,
|
|
210
|
+
lanes_identity: IDENTITY,
|
|
211
|
+
lanes_entities: ENTITIES,
|
|
212
212
|
};
|
|
213
213
|
|
|
214
214
|
/**
|
|
@@ -228,14 +228,14 @@ const OWNER_HABITS: Record<string, string> = {
|
|
|
228
228
|
*/
|
|
229
229
|
function habitsFor(reachable: readonly string[]): string[] {
|
|
230
230
|
const present = new Set(reachable);
|
|
231
|
-
const stores = present.has('
|
|
232
|
-
const people = present.has('
|
|
231
|
+
const stores = present.has('lanes_memory') && present.has('lanes_tasks');
|
|
232
|
+
const people = present.has('lanes_identity') && present.has('lanes_entities');
|
|
233
233
|
|
|
234
234
|
return reachable.flatMap((id) => {
|
|
235
|
-
if (stores && id === '
|
|
236
|
-
if (stores && id === '
|
|
237
|
-
if (people && id === '
|
|
238
|
-
if (people && id === '
|
|
235
|
+
if (stores && id === 'lanes_memory') return [MEMORY_AND_TASKS];
|
|
236
|
+
if (stores && id === 'lanes_tasks') return [];
|
|
237
|
+
if (people && id === 'lanes_identity') return [IDENTITY_AND_ENTITIES];
|
|
238
|
+
if (people && id === 'lanes_entities') return [];
|
|
239
239
|
return OWNER_HABITS[id] ? [OWNER_HABITS[id]!] : [];
|
|
240
240
|
});
|
|
241
241
|
}
|
|
@@ -270,7 +270,7 @@ function habitsFor(reachable: readonly string[]): string[] {
|
|
|
270
270
|
* the wrong address has already sent the message; the mistake happens at the
|
|
271
271
|
* instant of the send, before a skill would have been loaded, and the client
|
|
272
272
|
* most in need of the rule is again the one holding no skills directory. The
|
|
273
|
-
* paragraph also carries a rule nothing else can enforce: `
|
|
273
|
+
* paragraph also carries a rule nothing else can enforce: `lanes_entities_find` sets
|
|
274
274
|
* no error on an ambiguous result, so between "two candidates" and an agent
|
|
275
275
|
* using the first there is only prose.
|
|
276
276
|
*
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { forProfile } from '#auth';
|
|
2
|
+
import { clientLabelFrom } from './client-info.ts';
|
|
1
3
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
4
|
import { z } from 'zod';
|
|
3
5
|
import { isPromptResult } from '#connectivity';
|
|
@@ -48,17 +50,19 @@ export function registerPrompt(
|
|
|
48
50
|
description: describeWithConnections(capability.description, entry.reachable),
|
|
49
51
|
argsSchema: z.object(shape),
|
|
50
52
|
},
|
|
51
|
-
async (args: Record<string, unknown
|
|
53
|
+
async (args: Record<string, unknown>, extra?: unknown) => {
|
|
52
54
|
const { profile, connection, ...rest } = args;
|
|
53
55
|
const scope = resolveScope(entry, profile, connection);
|
|
54
56
|
if ('error' in scope) throw new Error(scope.error);
|
|
55
57
|
|
|
58
|
+
const label = clientLabelFrom(extra) ?? options.clientLabel;
|
|
59
|
+
|
|
56
60
|
const outcome = await options.profiles.get(scope.profile)!.dispatcher.invoke({
|
|
57
|
-
principal: options.principal,
|
|
61
|
+
principal: forProfile(options.principal, scope.profile),
|
|
58
62
|
capabilityId: id,
|
|
59
63
|
connectionKey: scope.connectionKey,
|
|
60
64
|
arguments: rest,
|
|
61
|
-
clientLabel:
|
|
65
|
+
...(label ? { clientLabel: label } : {}),
|
|
62
66
|
});
|
|
63
67
|
|
|
64
68
|
// A prompt has no `isError` to carry a refusal in, so a denial is a
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { forProfile } from '#auth';
|
|
2
|
+
import { clientLabelFrom } from './client-info.ts';
|
|
1
3
|
import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/server';
|
|
2
4
|
import { isResourceListResult, isResourceResult } from '#connectivity';
|
|
3
5
|
import type { DispatchOutcome } from '#dispatch';
|
|
@@ -28,14 +30,20 @@ export function registerResource(
|
|
|
28
30
|
const scope = { profile, connectionId };
|
|
29
31
|
const scoped = scopeResourceUri(capability.uriTemplate, scope);
|
|
30
32
|
|
|
31
|
-
const dispatch = (
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const dispatch = (
|
|
34
|
+
args: Record<string, unknown>,
|
|
35
|
+
extra?: unknown,
|
|
36
|
+
): Promise<DispatchOutcome> => {
|
|
37
|
+
const label = clientLabelFrom(extra) ?? options.clientLabel;
|
|
38
|
+
|
|
39
|
+
return runtime.dispatcher.invoke({
|
|
40
|
+
principal: forProfile(options.principal, profile),
|
|
34
41
|
capabilityId: id,
|
|
35
42
|
connectionKey,
|
|
36
43
|
arguments: args,
|
|
37
|
-
clientLabel:
|
|
44
|
+
...(label ? { clientLabel: label } : {}),
|
|
38
45
|
});
|
|
46
|
+
};
|
|
39
47
|
|
|
40
48
|
const metadata = {
|
|
41
49
|
description: `${capability.description} (${profile}: ${connectionKey})`,
|
|
@@ -44,8 +52,8 @@ export function registerResource(
|
|
|
44
52
|
|
|
45
53
|
// `uri` is the whole argument — the provider recovers its own template
|
|
46
54
|
// variables from it and never sees the routing segments core prepended.
|
|
47
|
-
const read = async (uri: URL) => {
|
|
48
|
-
const outcome = await dispatch({ uri: uri.href });
|
|
55
|
+
const read = async (uri: URL, _variables?: unknown, extra?: unknown) => {
|
|
56
|
+
const outcome = await dispatch({ uri: uri.href }, extra);
|
|
49
57
|
if (!outcome.ok) throw new Error(outcome.message);
|
|
50
58
|
if (!isResourceResult(outcome.result)) {
|
|
51
59
|
throw new Error(`${id} did not return resource contents`);
|
|
@@ -83,8 +91,8 @@ export function registerResource(
|
|
|
83
91
|
// rather than an oversight. A provider omits `list` when its resource
|
|
84
92
|
// space is unbounded.
|
|
85
93
|
list: capability.list
|
|
86
|
-
? async () => {
|
|
87
|
-
const outcome = await dispatch({});
|
|
94
|
+
? async (extra?: unknown) => {
|
|
95
|
+
const outcome = await dispatch({}, extra);
|
|
88
96
|
if (!outcome.ok) throw new Error(outcome.message);
|
|
89
97
|
if (!isResourceListResult(outcome.result)) return { resources: [] };
|
|
90
98
|
|
|
@@ -16,8 +16,8 @@ import type { MergedCapability, ProfileRuntime } from './visibility.ts';
|
|
|
16
16
|
* They have to go somewhere that works for *any* template, not just one that
|
|
17
17
|
* happens to spell `{key}`, so they are inserted as the first two path segments
|
|
18
18
|
* directly after the authority: `example://note/{key}` becomes
|
|
19
|
-
* `example://note/personal/a/{key}`, and `memory://entry/{id}` becomes
|
|
20
|
-
* `memory://entry/personal/owner/{id}`.
|
|
19
|
+
* `example://note/personal/a/{key}`, and `lanes-memory://entry/{id}` becomes
|
|
20
|
+
* `lanes-memory://entry/personal/owner/{id}`.
|
|
21
21
|
*
|
|
22
22
|
* The previous form substituted the literal token `{key}`, which meant any
|
|
23
23
|
* provider naming its variable anything else — every provider except `example` —
|
|
@@ -53,7 +53,7 @@ function originOf(uri: string): string {
|
|
|
53
53
|
/**
|
|
54
54
|
* Route the `resource_link`s a tool hands back.
|
|
55
55
|
*
|
|
56
|
-
* A provider names its own resources — `memory://entry/deploy_window` — because
|
|
56
|
+
* A provider names its own resources — `lanes-memory://entry/deploy_window` — because
|
|
57
57
|
* it does not know, and must not learn, which profile or connection it is
|
|
58
58
|
* serving. Core does. Without this, `memory.search` returns addresses that look
|
|
59
59
|
* like resources and cannot be read, which is worse than returning none.
|
package/src/server/mcp/tools.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
import { forProfile } from '#auth';
|
|
1
2
|
import { fromJsonSchema, type McpServer } from '@modelcontextprotocol/server';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { isToolResult } from '#connectivity';
|
|
5
|
+
import { clientLabelFrom } from './client-info.ts';
|
|
4
6
|
import { toolNameFor } from './naming.ts';
|
|
5
7
|
import { resourceLinkRouter } from './routing.ts';
|
|
6
8
|
import { sanitizeSchema } from './schema.ts';
|
|
7
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
accountsByProfile,
|
|
11
|
+
describeWithConnections,
|
|
12
|
+
type BuildServerOptions,
|
|
13
|
+
type MergedCapability,
|
|
14
|
+
} from './visibility.ts';
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
17
|
* Tools — the capability kind everything else is measured against.
|
|
@@ -49,7 +56,11 @@ export function registerDiscoveredTool(
|
|
|
49
56
|
toolNameFor(id),
|
|
50
57
|
{
|
|
51
58
|
...(discovered.title ? { title: discovered.title } : {}),
|
|
52
|
-
description: describeWithConnections(
|
|
59
|
+
description: describeWithConnections(
|
|
60
|
+
discovered.description,
|
|
61
|
+
entry.reachable,
|
|
62
|
+
accountsByProfile(options),
|
|
63
|
+
),
|
|
53
64
|
// Spread the upstream schema rather than rebuilding it from properties
|
|
54
65
|
// and required alone. Vendors put `$defs` beside those and `$ref` into
|
|
55
66
|
// them — Linear's attachment tools do — and a rebuild drops the
|
|
@@ -85,7 +96,11 @@ export function registerLocalTool(
|
|
|
85
96
|
toolNameFor(id),
|
|
86
97
|
{
|
|
87
98
|
...(capability.title ? { title: capability.title } : {}),
|
|
88
|
-
description: describeWithConnections(
|
|
99
|
+
description: describeWithConnections(
|
|
100
|
+
capability.description,
|
|
101
|
+
entry.reachable,
|
|
102
|
+
accountsByProfile(options),
|
|
103
|
+
),
|
|
89
104
|
inputSchema: {
|
|
90
105
|
...shape,
|
|
91
106
|
// Injected by core, never declared by a provider — ADR-001. Both enums
|
|
@@ -111,9 +126,13 @@ export function registerLocalTool(
|
|
|
111
126
|
function makeHandler(capabilityId: string, entry: MergedCapability, options: BuildServerOptions) {
|
|
112
127
|
// `unknown` because the JSON-Schema overload types it that way; the schema
|
|
113
128
|
// has already validated the shape by the time this runs.
|
|
114
|
-
return async (args: unknown) => {
|
|
129
|
+
return async (args: unknown, extra?: unknown) => {
|
|
115
130
|
const { profile, connection, ...rest } = (args ?? {}) as Record<string, unknown>;
|
|
116
131
|
|
|
132
|
+
// The request first, then whatever the transport knew before the call —
|
|
133
|
+
// which on a stateless POST is nothing, and is why the request is asked.
|
|
134
|
+
const label = clientLabelFrom(extra) ?? options.clientLabel;
|
|
135
|
+
|
|
117
136
|
const name = String(profile);
|
|
118
137
|
const runtime = options.profiles.get(name);
|
|
119
138
|
const reachable = entry.reachable.get(name);
|
|
@@ -147,11 +166,11 @@ function makeHandler(capabilityId: string, entry: MergedCapability, options: Bui
|
|
|
147
166
|
}
|
|
148
167
|
|
|
149
168
|
const outcome = await runtime.dispatcher.invoke({
|
|
150
|
-
principal: options.principal,
|
|
169
|
+
principal: forProfile(options.principal, name),
|
|
151
170
|
capabilityId,
|
|
152
171
|
connectionKey: String(connection),
|
|
153
172
|
arguments: rest,
|
|
154
|
-
clientLabel:
|
|
173
|
+
...(label ? { clientLabel: label } : {}),
|
|
155
174
|
});
|
|
156
175
|
|
|
157
176
|
if (!outcome.ok) {
|