@lanes-sh/link 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +135 -11
- package/package.json +3 -1
- package/src/cli/argv.ts +52 -0
- package/src/cli/commands/connect/authorise.ts +5 -0
- package/src/cli/commands/connect/custom/ask.ts +167 -0
- package/src/cli/commands/connect/custom/credential.ts +143 -0
- package/src/cli/commands/connect/custom/derive.ts +229 -0
- package/src/cli/commands/connect/custom/index.ts +285 -0
- package/src/cli/commands/connect/custom/prompts.ts +160 -0
- package/src/cli/commands/connect/custom/spec.ts +293 -0
- package/src/cli/commands/connect/custom/values.ts +53 -0
- package/src/cli/commands/connect/custom/write.ts +166 -0
- package/src/cli/commands/connect/grant.ts +27 -0
- package/src/cli/commands/connect/index.ts +24 -27
- package/src/cli/commands/connect/outcome.ts +3 -1
- package/src/cli/commands/connect/requirements.ts +11 -1
- package/src/cli/commands/connect/settle.ts +17 -0
- package/src/cli/commands/connect/setup.ts +9 -1
- package/src/cli/commands/connect/strategy.ts +87 -0
- package/src/cli/commands/connect/unknown.ts +41 -0
- package/src/cli/commands/mcp/list.ts +123 -29
- package/src/cli/identity.ts +29 -5
- package/src/cli/main.ts +42 -14
- package/src/cli/oauth.ts +89 -36
- package/src/cli/runtime/open.ts +13 -1
- package/src/cli/runtime/registry.ts +12 -0
- package/src/cli/selection.ts +12 -0
- package/src/cli/usage.ts +9 -1
- package/src/connectivity/auth/README.md +8 -1
- package/src/connectivity/auth/strategy/index.ts +128 -4
- package/src/connectivity/connector.ts +11 -0
- package/src/connectivity/index.ts +11 -1
- package/src/connectivity/manifest/auth.ts +19 -0
- package/src/connectivity/manifest/connector.ts +21 -0
- package/src/connectivity/manifest/primitives.ts +5 -1
- package/src/connectivity/manifest/provider.ts +30 -12
- package/src/connectivity/provider.ts +55 -0
- package/src/connectivity/transports/factory.ts +1 -0
- package/src/connectivity/transports/http/index.ts +73 -2
- package/src/dispatch/dispatch.ts +44 -5
- package/src/providers/bunq/hints.ts +45 -0
- package/src/providers/bunq/index.ts +87 -0
- package/src/providers/bunq/redact.ts +75 -0
- package/src/providers/bunq/specs/bunq.v1.json +883 -0
- package/src/providers/bunq/specs/vendor.ts +396 -0
- package/src/providers/bunq/strategy/handshake.ts +211 -0
- package/src/providers/bunq/strategy/index.ts +298 -0
- package/src/providers/bunq/strategy/keys.ts +72 -0
- package/src/providers/custom/index.ts +1 -6
- package/src/providers/custom/load.ts +56 -14
- package/src/providers/custom/template.ts +1 -1
- package/src/providers/discord/hints.ts +195 -0
- package/src/providers/discord/index.ts +121 -0
- package/src/providers/discord/redact.ts +99 -0
- package/src/providers/discord/specs/discord.v10.json +2333 -0
- package/src/providers/discord/specs/vendor.ts +164 -0
- package/src/providers/google/specs/vendor.ts +32 -317
- package/src/providers/index.ts +9 -0
- package/src/providers/reddit/index.ts +113 -0
- package/src/providers/reddit/oauth.ts +77 -0
- package/src/providers/reddit/redact.ts +33 -0
- package/src/providers/reddit/scopes.ts +27 -0
- package/src/providers/reddit/specs/reddit.v1.json +700 -0
- package/src/providers/scopes.ts +2 -0
- package/src/providers/shared/openapi.ts +155 -0
- package/src/providers/shared/vendor-operations.ts +179 -0
- package/src/providers/shared/vendor-spec.ts +309 -0
package/README.md
CHANGED
|
@@ -110,17 +110,25 @@ One command per account. Run it again to add a second mailbox, a second calendar
|
|
|
110
110
|
| Linear | `lanes link connect linear` |
|
|
111
111
|
| GitHub | `lanes link connect github` |
|
|
112
112
|
| Slack | `lanes link connect slack` |
|
|
113
|
+
| Reddit | `lanes link connect reddit` |
|
|
114
|
+
| Discord | `lanes link connect discord` |
|
|
113
115
|
| Gmail (IMAP, app password) | `lanes link connect gmail_imap` |
|
|
114
116
|
| Gmail (Google MCP) | `lanes link connect gmail_mcp` |
|
|
115
117
|
| Drive (Google MCP) | `lanes link connect drive_mcp` |
|
|
118
|
+
| bunq | `lanes link connect bunq` |
|
|
116
119
|
|
|
117
120
|
Three things worth knowing up front. `lanes link connect icloud` sets up Mail, Calendar, and
|
|
118
|
-
Contacts together, because one app-specific password covers all three.
|
|
121
|
+
Contacts together, because one app-specific password covers all three. Reddit is the one that does
|
|
122
|
+
need an app of your own — it rate-limits per client id, so a shared client would mean strangers
|
|
123
|
+
spending your budget. Google and Slack need no
|
|
119
124
|
OAuth client of your own: both authorise against the one Lanes operates, so there is no console to
|
|
120
125
|
visit — for Google, add `--own-client` if you would rather register your own, or take a service
|
|
121
126
|
account key or an app password over IMAP where you would rather nothing expired. And GitHub takes a
|
|
122
|
-
token you paste rather than a browser sign-in, because it will not register a client for us
|
|
123
|
-
is the one
|
|
127
|
+
token you paste rather than a browser sign-in, because it will not register a client for us. And
|
|
128
|
+
bunq — which wants a key from inside its app rather than a console at all — is the one that can move
|
|
129
|
+
money, and it says so: its payment tool executes immediately and is not reversible. Set a spending
|
|
130
|
+
limit on the API key while you are in there, and read
|
|
131
|
+
[docs/detailed/setup/bunq.md](docs/detailed/setup/bunq.md) before connecting it.
|
|
124
132
|
|
|
125
133
|
Full guide — what each one gives your agent, what it needs, and adding your own:
|
|
126
134
|
**[docs/connect.md](docs/connect.md)**.
|
|
@@ -56,8 +56,8 @@ to every future session.
|
|
|
56
56
|
stops a write is policy on the endpoint:
|
|
57
57
|
|
|
58
58
|
```console
|
|
59
|
-
$ lanes link policy deny memory.write
|
|
60
|
-
$ lanes link policy list
|
|
59
|
+
$ lanes link policy deny memory.write --profile <name> --target <name>
|
|
60
|
+
$ lanes link policy list --profile <name>
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
If you are running against a profile that grants writes, that is the owner's
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lanes-link
|
|
3
|
-
description: Use when the user refers to their own accounts, knowledge, procedures, or secrets through Lanes Link — "check my mail", "what do I know about X", "remember this", "which profile am I in" — or asks to connect, register, or set up their Lanes Link MCP server with this agent. Also covers what to do when a Lanes Link call is refused, or when the endpoint is not running.
|
|
3
|
+
description: Use when the user refers to their own accounts, knowledge, procedures, or secrets through Lanes Link — "check my mail", "what do I know about X", "remember this", "which profile am I in" — or asks to connect, register, or set up their Lanes Link MCP server with this agent. Also covers operating the workspace from a shell — adding or removing a profile, checking what a target serves, deploying an endpoint or recovering a lost deployment — and what to do when a Lanes Link call is refused, or when the endpoint is not running.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Lanes Link
|
|
@@ -24,12 +24,32 @@ is listed first.** Quietly picking one crosses the line the profile exists to
|
|
|
24
24
|
draw. There is no "current profile" to switch — the choice is made per call, and
|
|
25
25
|
`lanes link profile list` shows what exists.
|
|
26
26
|
|
|
27
|
-
**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
**What a command must be told is never inferred — but it is not always both.**
|
|
28
|
+
Nothing resolves from an environment variable or a config default, so a command
|
|
29
|
+
missing what it needs refuses rather than acting somewhere else. Passing a flag a
|
|
30
|
+
command does not read is refused too, which makes "add both to be safe" its own
|
|
31
|
+
failure. Four levels:
|
|
32
|
+
|
|
33
|
+
- **Neither.** `lanes link profile list`, `lanes link mcp list`,
|
|
34
|
+
`lanes link version`.
|
|
35
|
+
- **`--profile` alone.** `lanes link check`, `lanes link config show`,
|
|
36
|
+
`lanes link policy list`, `lanes link target list --profile <name>`,
|
|
37
|
+
`lanes link identity list`. Each is target-independent — one declaration in the
|
|
38
|
+
YAML that applies wherever the profile runs.
|
|
39
|
+
- **`--target`, with the profiles derived from it.** `lanes link status`,
|
|
40
|
+
`lanes link deploy` and `lanes link sync targets` act on one endpoint serving
|
|
41
|
+
every profile that declares that target. `--profile` is accepted and *narrows*
|
|
42
|
+
the answer; it does not choose the subject.
|
|
43
|
+
- **Both.** Everything acting on one account: `lanes link connect`,
|
|
44
|
+
`lanes link token rotate`, `lanes link secrets set`, `lanes link policy allow`,
|
|
45
|
+
`lanes link memory list`, `lanes link mcp add`.
|
|
46
|
+
|
|
47
|
+
`lanes link profile add` and `lanes link profile remove` **reject** `--profile`.
|
|
48
|
+
Both name their profile positionally, so a flag naming a second one could only
|
|
49
|
+
disagree with it.
|
|
50
|
+
|
|
51
|
+
When you write a command out for the owner, fill in what that command needs or
|
|
52
|
+
leave it as `<name>` for them to complete — never drop a required one.
|
|
33
53
|
|
|
34
54
|
A `connection` names an account within that profile. One profile may hold
|
|
35
55
|
several of the same kind, and naming a connection belonging to a *different*
|
|
@@ -162,8 +182,101 @@ the line.
|
|
|
162
182
|
**Never pass `--accept-broad-scopes` yourself.** When a provider asks for more than
|
|
163
183
|
it needs, print the scopes and let the owner add the flag. Deciding that is theirs.
|
|
164
184
|
|
|
165
|
-
A new connection is
|
|
166
|
-
|
|
185
|
+
**A new connection is served at once; the tools you were handed are not.**
|
|
186
|
+
Connecting publishes the config to wherever that target's endpoint reads it and
|
|
187
|
+
asks the endpoint to re-read it, so a `setup_overview` straight after connecting
|
|
188
|
+
*does* show the account. What has not changed is the set of tools this session
|
|
189
|
+
was given when it connected — the endpoint does not announce that its tools
|
|
190
|
+
changed, so a capability for a freshly connected account is not callable until
|
|
191
|
+
the client reconnects. Say that, rather than reporting the connection as missing
|
|
192
|
+
or asking them to connect again.
|
|
193
|
+
|
|
194
|
+
One exception, and it is the one that matters here: the authenticator is built
|
|
195
|
+
once at boot and is not re-read, so a `lanes link token rotate` does need the
|
|
196
|
+
endpoint restarted before the new token opens anything.
|
|
197
|
+
|
|
198
|
+
## Operating the workspace
|
|
199
|
+
|
|
200
|
+
If you have a shell, the commands that only *read* are yours to run without
|
|
201
|
+
asking: `lanes link status`, `lanes link check`, `lanes link plan`,
|
|
202
|
+
`lanes link doctor`, `lanes link profile list`, `lanes link target list`,
|
|
203
|
+
`lanes link target show`, `lanes link tools`, `lanes link config show` and
|
|
204
|
+
`lanes link audit tail`. None writes config, opens a browser, or costs anything,
|
|
205
|
+
and running one beats asking the owner to paste its output back. Give each what
|
|
206
|
+
its own level requires — they are not all the same, and the four levels are at
|
|
207
|
+
the top of this file.
|
|
208
|
+
|
|
209
|
+
**A command that writes runs `--dry-run` first, where it has one.** Show what it
|
|
210
|
+
reported and wait for an answer. `lanes link deploy`, `lanes link sync targets`,
|
|
211
|
+
`lanes link profile remove`, `lanes link secrets push` and `lanes link mcp add`
|
|
212
|
+
all take it. For a write with no dry run — `lanes link token rotate`,
|
|
213
|
+
`lanes link policy allow`, `lanes link secrets set` — say in one sentence what it
|
|
214
|
+
will change, then let them decide. Never a browser sign-in: that belongs to
|
|
215
|
+
whoever owns the account, as above.
|
|
216
|
+
|
|
217
|
+
**`--json` is not everywhere.** It parses on every command and is read by only
|
|
218
|
+
some, so one that ignores it prints its ordinary output and gives you nothing to
|
|
219
|
+
key on — do not treat the absence of JSON as a failure. `status`, `doctor`,
|
|
220
|
+
`tools`, `outputs`, `sync targets`, `target list`, `target show`, `profile list`,
|
|
221
|
+
`connect` and `setup plan` implement it. `deploy`, `check`, `plan`,
|
|
222
|
+
`config show`, `audit tail` and every `mcp` subcommand do not.
|
|
223
|
+
|
|
224
|
+
**A profile is created and removed, never switched.** `lanes link profile add
|
|
225
|
+
<name>` writes a new one and takes `--target <name>`, repeated once per target it
|
|
226
|
+
should declare. `lanes link profile remove <name>` takes `--dry-run` and then
|
|
227
|
+
`--yes`; given a `--target` it decommissions that one target's stores and leaves
|
|
228
|
+
the profile file in place. Neither reads `--profile`.
|
|
229
|
+
|
|
230
|
+
There is no current profile and no default target. `lanes link profile default`
|
|
231
|
+
and `lanes link target use` are gone and now refuse with an explanation — if you
|
|
232
|
+
meet one of those refusals, it is not a broken install, and there is no
|
|
233
|
+
replacement to find. The choice is made per command, on purpose.
|
|
234
|
+
|
|
235
|
+
## Deploying, and what it decides
|
|
236
|
+
|
|
237
|
+
`lanes link deploy` builds an image and rolls a revision. Its subject is a
|
|
238
|
+
**target**, and the profiles behind it are every profile declaring that target,
|
|
239
|
+
so one deploy serves all of them — there is no per-profile deploy to run and no
|
|
240
|
+
reason to loop over them.
|
|
241
|
+
|
|
242
|
+
**Always `--dry-run` first, and show what it printed.** It creates cloud
|
|
243
|
+
resources that cost money, it implements no `--json` to inspect instead, and that
|
|
244
|
+
plan is the only place the consequences are visible while they are still
|
|
245
|
+
avoidable.
|
|
246
|
+
|
|
247
|
+
Two things it refuses to guess, and both are the owner's to answer:
|
|
248
|
+
|
|
249
|
+
- **Whose bearer token opens the endpoint.** One token reaches every profile
|
|
250
|
+
behind that target, so this decides who gets in. With several candidates and
|
|
251
|
+
nothing recorded, it refuses and prints the command that names one.
|
|
252
|
+
- **A first deploy.** A target no profile declares yet has no set to derive from,
|
|
253
|
+
so `--profile` is required there. It may be repeated, and the first one named
|
|
254
|
+
is the primary.
|
|
255
|
+
|
|
256
|
+
**Never pass `--yes`, `--non-interactive`, `--access public` or
|
|
257
|
+
`--service-account` yourself.** Each settles a question about who can reach their
|
|
258
|
+
accounts. Print what the flag would decide and let them add it — the same rule
|
|
259
|
+
this file already applies to `--accept-broad-scopes`.
|
|
260
|
+
|
|
261
|
+
Deploying is how new code reaches the endpoint. It is not how an account gets
|
|
262
|
+
connected and not how a config change lands; both of those publish themselves.
|
|
263
|
+
|
|
264
|
+
## When the workspace and the endpoint disagree
|
|
265
|
+
|
|
266
|
+
A deployment records where it lives, and a workspace can lose that record — a new
|
|
267
|
+
machine, a reinstall, a profile file restored from something older. The endpoint
|
|
268
|
+
is still serving; what went missing is the config that describes it. The symptom
|
|
269
|
+
is a `lanes link status` that reports nothing deployed for a target you know is
|
|
270
|
+
up.
|
|
271
|
+
|
|
272
|
+
`lanes link sync targets` reconciles the two. `--discover` looks for a deployment
|
|
273
|
+
the workspace has no record of, `--from <location>` names one directly, and
|
|
274
|
+
`--dry-run` reports what it would merge without merging it. Run the dry run and
|
|
275
|
+
show it.
|
|
276
|
+
|
|
277
|
+
**`--prefer local` or `--prefer remote` is their answer, not yours.** It decides
|
|
278
|
+
which side wins where the two disagree, and the losing value is the one nobody
|
|
279
|
+
was asked about. Report what differs and let them pick.
|
|
167
280
|
|
|
168
281
|
## Registering it, and re-registering it
|
|
169
282
|
|
|
@@ -192,7 +305,7 @@ substitution form. If you have already printed one by accident, say so and offer
|
|
|
192
305
|
Prefer `lanes link mcp add --profile <name> --target <name>` to writing the command yourself: it checks the
|
|
193
306
|
endpoint is reachable, refuses to silently shadow an existing registration, and
|
|
194
307
|
cannot mistype the token. For a harness it does not know, take the command from
|
|
195
|
-
`lanes link outputs
|
|
308
|
+
`lanes link outputs --profile <name> --target <name>` rather than writing it blind — that command checks whether
|
|
196
309
|
`lanes` resolves on this machine and prints a longer working form if it does
|
|
197
310
|
not, where guessing gives you an empty substitution, a `Bearer ` header, and a
|
|
198
311
|
401 that reads as a bad token.
|
|
@@ -207,9 +320,20 @@ export LANES_LINK_TOKEN="$(lanes link token show --raw --profile <name> --target
|
|
|
207
320
|
One registration covers every profile. Do not add one per profile; they share a
|
|
208
321
|
URL and a token.
|
|
209
322
|
|
|
323
|
+
`lanes link mcp list` needs neither flag and reports whether the registration and
|
|
324
|
+
this document are current, out of date, or absent. That is the cheap first
|
|
325
|
+
question when behaviour does not match what this file describes — an out-of-date
|
|
326
|
+
copy means the rules you are reading are not the ones that shipped.
|
|
327
|
+
|
|
328
|
+
Claude Desktop cannot be handed a URL, so it spawns the endpoint over stdio
|
|
329
|
+
instead. That one is named in the client's own config file rather than registered
|
|
330
|
+
by a command, as `lanes link mcp stdio --profile <name> --target <name>`; both
|
|
331
|
+
flags are required, and nothing may be written to stdout.
|
|
332
|
+
|
|
210
333
|
## When it is not running
|
|
211
334
|
|
|
212
|
-
`lanes link start
|
|
335
|
+
`lanes link start --profile <name> --target <name>` runs in the foreground and
|
|
336
|
+
serves until stopped. Tell the
|
|
213
337
|
user the command rather than backgrounding it silently on their behalf.
|
|
214
338
|
Registration works while it is down — the harness simply cannot reach it yet,
|
|
215
339
|
and the first symptom is a failed call much later.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lanes-sh/link",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://lanes.sh/link",
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
"typecheck": "tsc --noEmit",
|
|
50
50
|
"lanes": "bun run ./src/cli/lanes.ts",
|
|
51
51
|
"audit": "bun pm scan",
|
|
52
|
+
"vendor:bunq": "bun run ./src/providers/bunq/specs/vendor.ts",
|
|
53
|
+
"vendor:discord": "bun run ./src/providers/discord/specs/vendor.ts",
|
|
52
54
|
"vendor:google": "bun run ./src/providers/google/specs/vendor.ts"
|
|
53
55
|
},
|
|
54
56
|
"imports": {
|
package/src/cli/argv.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { GlobalFlags } from './runtime.ts';
|
|
2
2
|
import type { OwnerFlags } from './commands/owner.ts';
|
|
3
3
|
import type { KnowledgeFlags } from './commands/knowledge.ts';
|
|
4
|
+
import type { CustomFlags } from './commands/connect/custom/spec.ts';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Turning argv into a command path and a flag bag.
|
|
@@ -142,3 +143,54 @@ export function knowledgeFlags(flags: Flags): KnowledgeFlags {
|
|
|
142
143
|
json: flags['json'] === true,
|
|
143
144
|
};
|
|
144
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `lanes link connect custom`'s flags.
|
|
149
|
+
*
|
|
150
|
+
* Here for the reason `ownerFlags` and `knowledgeFlags` are: the kebab-case
|
|
151
|
+
* spellings belong in the one place that parses argv. The list flags go through
|
|
152
|
+
* `all(argv, …)` rather than through `flags`, because `parseArgv` keeps only the
|
|
153
|
+
* last value of a repeated flag — so `--scopes a --scopes b` would silently
|
|
154
|
+
* become `b`, and a scope quietly dropped is a token that works until it does
|
|
155
|
+
* not.
|
|
156
|
+
*
|
|
157
|
+
* Which of these are read at all depends on the connectivity type and the
|
|
158
|
+
* credential type chosen; that is `custom/spec.ts`'s to know. This only spells
|
|
159
|
+
* them.
|
|
160
|
+
*/
|
|
161
|
+
export function customFlags(flags: Flags, argv: readonly string[]): CustomFlags {
|
|
162
|
+
return {
|
|
163
|
+
connector: text(flags, 'connector'),
|
|
164
|
+
auth: text(flags, 'auth'),
|
|
165
|
+
name: text(flags, 'name'),
|
|
166
|
+
description: text(flags, 'description'),
|
|
167
|
+
endpoint: text(flags, 'endpoint'),
|
|
168
|
+
baseUrl: text(flags, 'base-url'),
|
|
169
|
+
openapi: text(flags, 'openapi'),
|
|
170
|
+
operations: all(argv, 'operations'),
|
|
171
|
+
service: text(flags, 'service'),
|
|
172
|
+
host: text(flags, 'host'),
|
|
173
|
+
port: text(flags, 'port'),
|
|
174
|
+
smtpHost: text(flags, 'smtp-host'),
|
|
175
|
+
smtpPort: text(flags, 'smtp-port'),
|
|
176
|
+
root: text(flags, 'root'),
|
|
177
|
+
exclude: all(argv, 'exclude'),
|
|
178
|
+
header: all(argv, 'header'),
|
|
179
|
+
authHeader: text(flags, 'auth-header'),
|
|
180
|
+
authQuery: text(flags, 'auth-query'),
|
|
181
|
+
scopes: all(argv, 'scopes'),
|
|
182
|
+
authorizeUrl: text(flags, 'authorize-url'),
|
|
183
|
+
tokenUrl: text(flags, 'token-url'),
|
|
184
|
+
clientApp: text(flags, 'client-app'),
|
|
185
|
+
registration: text(flags, 'registration'),
|
|
186
|
+
redirectUri: text(flags, 'redirect-uri'),
|
|
187
|
+
authorizeParam: all(argv, 'authorize-param'),
|
|
188
|
+
strategy: text(flags, 'strategy'),
|
|
189
|
+
strategyOption: all(argv, 'strategy-option'),
|
|
190
|
+
identityUrl: text(flags, 'identity-url'),
|
|
191
|
+
identityField: text(flags, 'identity-field'),
|
|
192
|
+
setupDocs: text(flags, 'setup-docs'),
|
|
193
|
+
replaceManifest: flags['replace-manifest'] === true,
|
|
194
|
+
yes: flags['yes'] === true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -283,6 +283,11 @@ async function authoriseDirect(input: {
|
|
|
283
283
|
...(client.kind === 'brokered' && client.config.redirectUri
|
|
284
284
|
? { relayRedirect: client.config.redirectUri }
|
|
285
285
|
: {}),
|
|
286
|
+
// The other spelling of the same need, for a vendor that takes a loopback
|
|
287
|
+
// redirect but matches it exactly. The manifest owns this URL because the
|
|
288
|
+
// vendor's console does — it has to be the string registered there, and
|
|
289
|
+
// `defineProvider` refuses a manifest declaring both this and a broker.
|
|
290
|
+
...(manifest.auth.redirect_uri ? { fixedRedirect: manifest.auth.redirect_uri } : {}),
|
|
286
291
|
scopes,
|
|
287
292
|
connectionLabel: manifest.name,
|
|
288
293
|
...(manifest.auth.authorize_params
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { style } from '../../../output.ts';
|
|
2
|
+
import type { Prompter } from '../../../prompt.ts';
|
|
3
|
+
import { parseAuthMethod, parseConnectorKind, refuseIllegalPair } from './derive.ts';
|
|
4
|
+
import {
|
|
5
|
+
AUTH_FIELDS,
|
|
6
|
+
AUTH_METHODS,
|
|
7
|
+
CONNECTOR_FIELDS,
|
|
8
|
+
CONNECTOR_KINDS,
|
|
9
|
+
camel,
|
|
10
|
+
titleCase,
|
|
11
|
+
type CustomAnswers,
|
|
12
|
+
type CustomFlags,
|
|
13
|
+
type FieldSpec,
|
|
14
|
+
} from './spec.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Collecting what the flags did not say.
|
|
18
|
+
*
|
|
19
|
+
* Every value has a flag, so the whole command is scriptable — but the fields
|
|
20
|
+
* differ per connectivity type and per credential type, and nobody holds thirty
|
|
21
|
+
* flag names in their head to declare one provider. So a missing required value
|
|
22
|
+
* is asked for, in a fixed order, and the answers are indistinguishable from
|
|
23
|
+
* having been typed.
|
|
24
|
+
*
|
|
25
|
+
* Under `--non-interactive` nothing is asked and *everything* missing is named
|
|
26
|
+
* at once, with the command to re-run. One round trip rather than one refusal
|
|
27
|
+
* per flag, because the caller there is usually a script or an agent and each
|
|
28
|
+
* refusal costs it a whole run.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Named, not asked: nothing can be collected until the two lists are picked. */
|
|
32
|
+
export interface Blocked {
|
|
33
|
+
readonly missing: readonly string[];
|
|
34
|
+
readonly command: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function collect(
|
|
38
|
+
id: string,
|
|
39
|
+
flags: CustomFlags,
|
|
40
|
+
prompter: Prompter,
|
|
41
|
+
/** How to spell the re-run, when there is nothing to ask. */
|
|
42
|
+
commandFor: (missing: readonly string[]) => string,
|
|
43
|
+
): Promise<CustomAnswers | Blocked> {
|
|
44
|
+
const missing: string[] = [];
|
|
45
|
+
|
|
46
|
+
const connector = flags.connector
|
|
47
|
+
? parseConnectorKind(flags.connector)
|
|
48
|
+
: prompter.interactive
|
|
49
|
+
? parseConnectorKind(await choose(prompter, 'How is this service reached?', CONNECTOR_KINDS))
|
|
50
|
+
: (missing.push('connector'), undefined);
|
|
51
|
+
|
|
52
|
+
const auth = flags.auth
|
|
53
|
+
? parseAuthMethod(flags.auth)
|
|
54
|
+
: prompter.interactive
|
|
55
|
+
? parseAuthMethod(await choose(prompter, 'How does it authenticate?', AUTH_METHODS))
|
|
56
|
+
: (missing.push('auth'), undefined);
|
|
57
|
+
|
|
58
|
+
// Which fields exist at all depends on the two answers above, so a
|
|
59
|
+
// non-interactive run missing either cannot list the rest yet. Saying so beats
|
|
60
|
+
// guessing at a list that would be wrong.
|
|
61
|
+
if (!connector || !auth) return { missing, command: commandFor(missing) };
|
|
62
|
+
|
|
63
|
+
// Before a single field is asked for. The pair is decided by the two answers
|
|
64
|
+
// above, so a combination that cannot work is knowable here — and asking six
|
|
65
|
+
// questions about a mailbox that will be refused for its credential type is
|
|
66
|
+
// worse than refusing straight away.
|
|
67
|
+
refuseIllegalPair(connector, auth);
|
|
68
|
+
|
|
69
|
+
const fields = [...CONNECTOR_FIELDS[connector], ...AUTH_FIELDS[auth], ...EXTRA];
|
|
70
|
+
const values: Record<string, string | readonly string[]> = {};
|
|
71
|
+
|
|
72
|
+
for (const field of fields) {
|
|
73
|
+
const given = flags[camel(field.flag) as keyof CustomFlags];
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(given)) {
|
|
76
|
+
if (given.length > 0) values[field.flag] = given;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (typeof given === 'string' && given.length > 0) {
|
|
80
|
+
values[field.flag] = given;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!field.required) continue;
|
|
84
|
+
|
|
85
|
+
if (!prompter.interactive) {
|
|
86
|
+
missing.push(field.flag);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const answer = field.choices
|
|
91
|
+
? await choose(prompter, field.label, field.choices)
|
|
92
|
+
: await askFor(prompter, field);
|
|
93
|
+
|
|
94
|
+
if (answer.length > 0) values[field.flag] = answer;
|
|
95
|
+
else missing.push(field.flag);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (missing.length > 0) return { missing, command: commandFor(missing) };
|
|
99
|
+
|
|
100
|
+
const name = flags.name ?? (prompter.interactive ? await askName(prompter, id) : titleCase(id));
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
name,
|
|
105
|
+
...(flags.description ? { description: flags.description } : {}),
|
|
106
|
+
connector,
|
|
107
|
+
auth,
|
|
108
|
+
values,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fields that belong to no single kind.
|
|
114
|
+
*
|
|
115
|
+
* All optional, so a non-interactive run is never blocked on one — and none is
|
|
116
|
+
* prompted for, because a question nobody needs to answer is worse than a flag
|
|
117
|
+
* nobody types. `derive.ts` refuses the combinations that do not work.
|
|
118
|
+
*/
|
|
119
|
+
const EXTRA: readonly FieldSpec[] = [
|
|
120
|
+
{ flag: 'identity-url', label: 'Identity URL', required: false },
|
|
121
|
+
{ flag: 'identity-field', label: 'Identity field', required: false },
|
|
122
|
+
{ flag: 'setup-docs', label: 'Where to get the credential', required: false },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
async function askFor(prompter: Prompter, field: FieldSpec): Promise<string> {
|
|
126
|
+
if (field.hint) print(style.dim(` ${field.hint}`));
|
|
127
|
+
return (await prompter.ask(`${field.label}: `)).trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function askName(prompter: Prompter, id: string): Promise<string> {
|
|
131
|
+
const suggested = titleCase(id);
|
|
132
|
+
const answer = (await prompter.ask(`Display name [${suggested}]: `)).trim();
|
|
133
|
+
return answer.length > 0 ? answer : suggested;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A closed set, offered as a numbered list.
|
|
138
|
+
*
|
|
139
|
+
* The same shape `chooseAuthMethod` uses for a provider with more than one way
|
|
140
|
+
* in, for the same reason: these are the members of a union, and typing one from
|
|
141
|
+
* memory is how somebody discovers a name is `api-key` and not `apikey` by
|
|
142
|
+
* being refused.
|
|
143
|
+
*/
|
|
144
|
+
async function choose(
|
|
145
|
+
prompter: Prompter,
|
|
146
|
+
question: string,
|
|
147
|
+
options: readonly string[],
|
|
148
|
+
): Promise<string> {
|
|
149
|
+
print('');
|
|
150
|
+
print(question);
|
|
151
|
+
options.forEach((option, index) => print(` ${index + 1}. ${option}`));
|
|
152
|
+
|
|
153
|
+
const answer = (await prompter.ask(`Choose 1-${options.length}: `)).trim();
|
|
154
|
+
const index = Number(answer);
|
|
155
|
+
|
|
156
|
+
// A name is accepted too. Somebody who already knows it should not have to
|
|
157
|
+
// count, and the numbers exist for somebody who does not.
|
|
158
|
+
if (options.includes(answer)) return answer;
|
|
159
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1]!;
|
|
160
|
+
|
|
161
|
+
throw new Error(`"${answer}" is not one of: ${options.join(', ')}.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Prompts and their hints go to stderr, so `--json` leaves stdout clean. */
|
|
165
|
+
function print(line: string): void {
|
|
166
|
+
process.stderr.write(`${line}\n`);
|
|
167
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { AUTH_KIND, type CustomAnswers } from './spec.ts';
|
|
2
|
+
import { many, one, pairs } from './values.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The credential block, and the OAuth rules the schema does not carry.
|
|
6
|
+
*
|
|
7
|
+
* Split from `derive.ts` because it fails for a different reason. That file is
|
|
8
|
+
* about a connectivity type's fields, which are either given or defaulted; this
|
|
9
|
+
* one is about arrangements that *validate* and then cannot run — an
|
|
10
|
+
* authorization server with nowhere to send the browser, a client nobody
|
|
11
|
+
* registered, a header the transport will not read. Each of those surfaces after
|
|
12
|
+
* a credential has been stored or a consent screen approved, so each is refused
|
|
13
|
+
* here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function authBlock(answers: CustomAnswers): Record<string, unknown> {
|
|
17
|
+
const method = answers.auth;
|
|
18
|
+
const kind = AUTH_KIND[method];
|
|
19
|
+
const header = one(answers, 'auth-header');
|
|
20
|
+
const query = one(answers, 'auth-query');
|
|
21
|
+
|
|
22
|
+
if (method === 'none' || method === 'basic') return { kind };
|
|
23
|
+
|
|
24
|
+
if (method === 'bearer') {
|
|
25
|
+
if (header && answers.connector === 'mcp') {
|
|
26
|
+
throw new Error(
|
|
27
|
+
'An mcp connector always sends its token as "Authorization: Bearer", so --auth-header ' +
|
|
28
|
+
`("${header}") could not be honoured — the transport never reads the resolved credential, ` +
|
|
29
|
+
'only the token, and the header would be dropped in silence.\n' +
|
|
30
|
+
' Drop the flag, or reach this service with --connector http.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return { kind, ...(header ? { header } : {}) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (method === 'header') {
|
|
37
|
+
if (query) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'--auth-query is only meaningful for --auth api-key, which may put its key in the query ' +
|
|
40
|
+
'string. A "header" credential is sent in a header of its own and nothing forwards a query ' +
|
|
41
|
+
'parameter, so this would be dropped in silence.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return { kind, header };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (method === 'strategy') {
|
|
48
|
+
// Whether the name resolves is the registry's to answer, not this file's: a
|
|
49
|
+
// strategy travels on a provider's definition, and `strategyFor` looks first
|
|
50
|
+
// at this manifest's own and then at every other registered provider's.
|
|
51
|
+
// `refuseStrategy` is what says a name reaches nothing, and it can list what
|
|
52
|
+
// does — which is why this does not try to.
|
|
53
|
+
const named = one(answers, 'strategy');
|
|
54
|
+
const options = pairs(answers, 'strategy-option');
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
kind,
|
|
58
|
+
strategy: named,
|
|
59
|
+
...(options ? { options } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (method === 'api-key') {
|
|
64
|
+
if (header && query) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'--auth-header and --auth-query contradict: a key goes in a header or in the query string, ' +
|
|
67
|
+
'and declaring both leaves which one is sent up to whichever the transport reads last.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return { kind, ...(header ? { header } : {}), ...(query ? { query } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return oauthBlock(answers);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The one credential type with rules the schema does not carry.
|
|
78
|
+
*
|
|
79
|
+
* Each of these produces a manifest `defineProvider` accepts and `authorise`
|
|
80
|
+
* cannot run, which is worse than an invalid one: the operator finds out after
|
|
81
|
+
* approving a consent screen, or not until the first call.
|
|
82
|
+
*/
|
|
83
|
+
function oauthBlock(answers: CustomAnswers): Record<string, unknown> {
|
|
84
|
+
const scopes = many(answers, 'scopes');
|
|
85
|
+
const authorizeUrl = one(answers, 'authorize-url');
|
|
86
|
+
const tokenUrl = one(answers, 'token-url');
|
|
87
|
+
const app = one(answers, 'client-app');
|
|
88
|
+
const declared = one(answers, 'registration');
|
|
89
|
+
const redirect = one(answers, 'redirect-uri');
|
|
90
|
+
const authorizeParams = pairs(answers, 'authorize-param');
|
|
91
|
+
|
|
92
|
+
if (Boolean(authorizeUrl) !== Boolean(tokenUrl)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'OAuth endpoints are declared together or not at all. `authorise` takes the direct path only ' +
|
|
95
|
+
'when both are present, so half a pair is ignored on an mcp connector and fatal on an http ' +
|
|
96
|
+
'one.\n Add the missing one of --authorize-url / --token-url, or drop both.',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A REST API has no MCP metadata document to discover from, so a client has
|
|
101
|
+
// to come from somewhere the manifest names.
|
|
102
|
+
const registration =
|
|
103
|
+
declared ?? (answers.connector === 'mcp' && !authorizeUrl && !app ? 'dynamic' : 'manual');
|
|
104
|
+
|
|
105
|
+
if (registration === 'dynamic' && answers.connector === 'http') {
|
|
106
|
+
throw new Error(
|
|
107
|
+
'Dynamic client registration is something an authorization server offers, and a REST API ' +
|
|
108
|
+
'publishes no registration endpoint — there would be no client to authorise with.\n' +
|
|
109
|
+
' Register a client with the vendor and drop --registration, which then defaults to manual.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (registration === 'dynamic' && authorizeUrl) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'Declaring both endpoints is what takes an mcp connector off the SDK\'s own OAuth path and onto ' +
|
|
116
|
+
'ours, and ours needs a client to present (ADR-040). Registration cannot be dynamic there.\n' +
|
|
117
|
+
' Drop --registration dynamic, or drop the two endpoint URLs and let the SDK register.',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (registration === 'manual' && answers.connector === 'http' && !authorizeUrl) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'An http connector has no metadata document to discover an authorization server from: a REST ' +
|
|
124
|
+
'API is a base URL, and where consent happens is not something it announces.\n' +
|
|
125
|
+
' Add --authorize-url and --token-url.',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
kind: 'oauth',
|
|
131
|
+
registration,
|
|
132
|
+
...(registration === 'manual' ? { app: app ?? answers.id } : {}),
|
|
133
|
+
scopes,
|
|
134
|
+
...(authorizeUrl ? { authorize_url: authorizeUrl } : {}),
|
|
135
|
+
...(tokenUrl ? { token_url: tokenUrl } : {}),
|
|
136
|
+
...(redirect ? { redirect_uri: redirect } : {}),
|
|
137
|
+
// Google needs `access_type=offline` and `prompt=consent`, without which it
|
|
138
|
+
// returns an access token and no refresh token — so the connection works for
|
|
139
|
+
// an hour and then dies, which is a miserable thing to debug. Any vendor can
|
|
140
|
+
// have one of these, and nothing but the vendor's own docs will say so.
|
|
141
|
+
...(authorizeParams ? { authorize_params: authorizeParams } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|