@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
|
@@ -27,6 +27,7 @@ export async function settleIdentity(input: {
|
|
|
27
27
|
credentials: SecretStore;
|
|
28
28
|
registry: { manifest(id: string): ProviderManifest | undefined };
|
|
29
29
|
connectorFor(providerId: string, connectionId: string): AnyConnector | undefined;
|
|
30
|
+
authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
|
|
30
31
|
};
|
|
31
32
|
prompter?: Prompter;
|
|
32
33
|
}): Promise<{ connectionId: string; account: string }> {
|
|
@@ -51,8 +52,24 @@ export async function settleIdentity(input: {
|
|
|
51
52
|
if (!account) {
|
|
52
53
|
const token = () => bearerTokenAsStored(manifest, provisionalId, runtime.credentials);
|
|
53
54
|
|
|
55
|
+
// Only where `accessToken` cannot answer: `requestAuthorizer` goes through
|
|
56
|
+
// `credentialResolver`, which may *refresh* an OAuth token, and
|
|
57
|
+
// `bearerTokenAsStored` is deliberately the connect-time variant that does
|
|
58
|
+
// not. Leaving oauth and bearer on the token keeps refresh behaviour
|
|
59
|
+
// exactly as it was; these three had no working path at all.
|
|
60
|
+
const carriesItsOwnHeader =
|
|
61
|
+
manifest.auth.kind === 'api_key' ||
|
|
62
|
+
manifest.auth.kind === 'header' ||
|
|
63
|
+
manifest.auth.kind === 'basic';
|
|
64
|
+
|
|
54
65
|
account = await resolveAccount(manifest, {
|
|
55
66
|
accessToken: token,
|
|
67
|
+
...(carriesItsOwnHeader
|
|
68
|
+
? {
|
|
69
|
+
authorize: (request: Request) =>
|
|
70
|
+
runtime.authorizeRequest(manifest.id, provisionalId, request),
|
|
71
|
+
}
|
|
72
|
+
: {}),
|
|
56
73
|
// A protocol that authenticates by username has nothing to GET and no
|
|
57
74
|
// tool to call — it knows, once the server has accepted the login.
|
|
58
75
|
identify: async () =>
|
|
@@ -145,7 +145,15 @@ export async function ensureStaticCredential(input: {
|
|
|
145
145
|
const { manifest, connectionId, credentials, replace, provisional } = input;
|
|
146
146
|
const prompter = input.prompter ?? terminalPrompter;
|
|
147
147
|
const auth = manifest.auth;
|
|
148
|
-
|
|
148
|
+
// `strategy` used to be excluded here, from when no strategy existed and the
|
|
149
|
+
// kind was unreachable. It is exactly the wrong exclusion: a strategy
|
|
150
|
+
// provider's credential is *more* of a pasted value than most — bunq's API
|
|
151
|
+
// key is the input its handshake runs on — and skipping the prompt left
|
|
152
|
+
// `lanes link connect bunq` storing nothing and then failing inside the
|
|
153
|
+
// handshake with "no API key was stored", on the interactive path the setup
|
|
154
|
+
// documentation describes. Everything below is generic: the ref derives, the
|
|
155
|
+
// prompts come from the manifest.
|
|
156
|
+
if (auth.kind === 'none' || auth.kind === 'oauth') return;
|
|
149
157
|
|
|
150
158
|
const ref = credentialRefForConnection(manifest, connectionId)!;
|
|
151
159
|
const stored = await credentials.has(ref);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { credentialRefForConnection, strategyContextFrom, strategyFor } from '#connectivity';
|
|
2
|
+
import type { ProviderManifest } from '#connectivity';
|
|
3
|
+
import { createScopedStore, scopeNamespace } from '#dispatch';
|
|
4
|
+
import { scopeSecrets } from '#secrets';
|
|
5
|
+
import type { SecretStore } from '#secrets';
|
|
6
|
+
import type { RuntimeState } from '#stores/state';
|
|
7
|
+
import type { ProviderRegistry } from '#registry';
|
|
8
|
+
import { progress, style } from '../../output.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The handshake a strategy provider needs before it can be called at all.
|
|
12
|
+
*
|
|
13
|
+
* Ordinary providers finish authenticating the moment the operator pastes
|
|
14
|
+
* something: the value *is* the credential. A strategy provider has only half
|
|
15
|
+
* of one at that point — bunq's API key is the input to an installation, not a
|
|
16
|
+
* token — so this runs the vendor's own setup between storing what was pasted
|
|
17
|
+
* and asking whose account it is.
|
|
18
|
+
*
|
|
19
|
+
* **Why it sits where it does in `connect`.** After the credential is stored,
|
|
20
|
+
* because the handshake's input is what was pasted. Before identity is settled
|
|
21
|
+
* and anything is written to the config, because a key the vendor rejects
|
|
22
|
+
* should fail here — with the vendor's own message — rather than leaving a
|
|
23
|
+
* connection row describing an account that cannot be reached. It runs under
|
|
24
|
+
* the *provisional* connection id for the same reason every other step does:
|
|
25
|
+
* the real one is not known yet, and `connect` moves the credential when it is.
|
|
26
|
+
*
|
|
27
|
+
* Here rather than in `index.ts` because that file is at its size budget, and
|
|
28
|
+
* because this is one self-contained step: everything it needs is a manifest
|
|
29
|
+
* and a runtime, and everything it produces is in the credential store.
|
|
30
|
+
*/
|
|
31
|
+
export async function runStrategySetup(
|
|
32
|
+
manifest: ProviderManifest,
|
|
33
|
+
/** The provisional id. `connect` renames the connection afterwards and the credential follows. */
|
|
34
|
+
connectionId: string,
|
|
35
|
+
/** Structurally the CLI `Runtime`, named as the parts this actually reaches. */
|
|
36
|
+
runtime: {
|
|
37
|
+
readonly registry: ProviderRegistry;
|
|
38
|
+
readonly credentials: SecretStore;
|
|
39
|
+
readonly state: RuntimeState;
|
|
40
|
+
readonly resolution: { readonly profile: string };
|
|
41
|
+
},
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const { registry, credentials, state } = runtime;
|
|
44
|
+
const profile = runtime.resolution.profile;
|
|
45
|
+
if (manifest.auth.kind !== 'strategy') return;
|
|
46
|
+
|
|
47
|
+
const strategy = strategyFor(manifest, registry);
|
|
48
|
+
if (!strategy.setup) return;
|
|
49
|
+
|
|
50
|
+
// The one ref this connection may read, which is also the one the pasted
|
|
51
|
+
// value landed in and the one the handshake will rewrite.
|
|
52
|
+
const ref = credentialRefForConnection(manifest, connectionId)!;
|
|
53
|
+
|
|
54
|
+
const context = strategyContextFrom({
|
|
55
|
+
source: {
|
|
56
|
+
credentials: scopeSecrets(credentials, [ref]),
|
|
57
|
+
state: createScopedStore(state, scopeNamespace(manifest.id, connectionId)),
|
|
58
|
+
log: {
|
|
59
|
+
debug: () => {},
|
|
60
|
+
info: (message) => progress(style.dim(` ${message}`)),
|
|
61
|
+
warn: (message) => progress(style.dim(` ${message}`)),
|
|
62
|
+
error: (message) => progress(style.dim(` ${message}`)),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
manifest,
|
|
66
|
+
connectionId,
|
|
67
|
+
profile,
|
|
68
|
+
// Writable *only* here — the dispatch path passes nothing, so a per-request
|
|
69
|
+
// handshake cannot persist anything.
|
|
70
|
+
//
|
|
71
|
+
// Scoped to the same single ref the reads are. Handing over the raw store
|
|
72
|
+
// would leave the write side of this boundary open while the read side is
|
|
73
|
+
// shut, which is the asymmetry that makes a boundary decorative: a strategy
|
|
74
|
+
// could not *read* `google/main` and could quietly overwrite it.
|
|
75
|
+
write: async (reference, value) => {
|
|
76
|
+
if (reference !== ref) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`The ${manifest.name} strategy tried to write ${reference}, which is not its connection's credential (${ref}).`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
await credentials.set(reference, value);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
progress(style.dim(` Registering this device with ${manifest.name}…`));
|
|
86
|
+
await strategy.setup(context);
|
|
87
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { layout } from '#profile';
|
|
2
|
+
import type { ProviderRegistry } from '#registry';
|
|
3
|
+
import { PROGRAM } from '../../usage.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What to say when a name resolves to no provider.
|
|
7
|
+
*
|
|
8
|
+
* The one message that has to teach something, because a misspelling and a
|
|
9
|
+
* service nobody has integrated arrive here identically and want opposite
|
|
10
|
+
* answers. So it prints both lists — what is shipped, and what this profile has
|
|
11
|
+
* added — and then the command that adds another.
|
|
12
|
+
*
|
|
13
|
+
* It named `<root>/providers/` for a long time, which is not where manifests
|
|
14
|
+
* live and never was. The path comes from `layout` now, like every other
|
|
15
|
+
* profile-owned path, and the "add your own" line is printed whether or not the
|
|
16
|
+
* operator already has one: showing it only to somebody with none meant hiding
|
|
17
|
+
* it from everybody except the one person who could not yet know it existed.
|
|
18
|
+
*/
|
|
19
|
+
export function unknownProvider(input: {
|
|
20
|
+
readonly providerId: string;
|
|
21
|
+
readonly registry: ProviderRegistry;
|
|
22
|
+
readonly workspaceRoot: string;
|
|
23
|
+
readonly profile: string;
|
|
24
|
+
readonly target: string;
|
|
25
|
+
}): Error {
|
|
26
|
+
const available = input.registry.list();
|
|
27
|
+
const builtin = available.filter((c) => c.origin === 'builtin').map((c) => c.manifest.id);
|
|
28
|
+
const yours = available.filter((c) => c.origin === 'workspace').map((c) => c.manifest.id);
|
|
29
|
+
|
|
30
|
+
const selection = `--profile ${input.profile} --target ${input.target}`;
|
|
31
|
+
const directory = `${input.workspaceRoot}/${layout.providers(input.profile)}`;
|
|
32
|
+
|
|
33
|
+
return new Error(
|
|
34
|
+
`Unknown provider "${input.providerId}".\n` +
|
|
35
|
+
` built in: ${builtin.join(', ')}\n` +
|
|
36
|
+
(yours.length > 0 ? ` yours: ${yours.join(', ')}\n` : '') +
|
|
37
|
+
` add your own: ${PROGRAM} connect custom ${input.providerId}` +
|
|
38
|
+
` --connector <kind> --auth <method> ${selection}\n` +
|
|
39
|
+
` or a manifest in ${directory}\n`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -1,8 +1,46 @@
|
|
|
1
|
-
import { heading, print, style } from '../../output.ts';
|
|
1
|
+
import { emit, heading, print, style } from '../../output.ts';
|
|
2
2
|
import { assetState, plannedAssets, readAsset } from './assets.ts';
|
|
3
3
|
import { HARNESSES, type Harness } from './harnesses.ts';
|
|
4
4
|
import { exists } from './register.ts';
|
|
5
5
|
|
|
6
|
+
/** Why a document this harness can hold is not what we ship, if it is not. */
|
|
7
|
+
export type DocumentState = 'current' | 'stale' | 'missing' | 'unreadable';
|
|
8
|
+
|
|
9
|
+
export interface ListedDocument {
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly state: DocumentState;
|
|
13
|
+
/** Only for `unreadable` — what went wrong reading the bundled copy. */
|
|
14
|
+
readonly detail?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ListedHarness {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly label: string;
|
|
20
|
+
readonly installed: boolean;
|
|
21
|
+
/** The resolved binary, so a caller can see *which* claude answered. */
|
|
22
|
+
readonly binary: string | null;
|
|
23
|
+
readonly registered: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Empty when the harness is not installed, matching what the text rendering
|
|
26
|
+
* says: with no binary there is nothing to register the documents against, and
|
|
27
|
+
* reporting them would describe a setup that does not exist.
|
|
28
|
+
*/
|
|
29
|
+
readonly documents: readonly ListedDocument[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface McpListing {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly scope: string;
|
|
35
|
+
readonly harnesses: readonly ListedHarness[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface McpListFlags {
|
|
39
|
+
readonly name?: string | undefined;
|
|
40
|
+
readonly scope?: string | undefined;
|
|
41
|
+
readonly json?: boolean | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
6
44
|
/**
|
|
7
45
|
* `lanes link mcp list` — where this endpoint is registered, and whether what
|
|
8
46
|
* each harness has been told about it is still what we ship.
|
|
@@ -11,58 +49,114 @@ import { exists } from './register.ts';
|
|
|
11
49
|
* different fixes. A registration without the skill is a working endpoint
|
|
12
50
|
* nobody reaches for; a skill against no registration is a document describing
|
|
13
51
|
* tools that are not there.
|
|
52
|
+
*
|
|
53
|
+
* The listing is gathered before anything is printed, so `--json` and the text
|
|
54
|
+
* rendering describe the same snapshot rather than two probes taken a moment
|
|
55
|
+
* apart. `--json` exists because this is the one command another program has a
|
|
56
|
+
* reason to read: it is how a UI decides between "add" and "re-add", and the
|
|
57
|
+
* `stale` state is the only signal that a re-run would do something.
|
|
14
58
|
*/
|
|
15
|
-
export async function mcpList(options:
|
|
59
|
+
export async function mcpList(options: McpListFlags): Promise<void> {
|
|
16
60
|
const name = options.name ?? 'lanes-link';
|
|
17
61
|
const scope = options.scope ?? 'user';
|
|
18
62
|
|
|
19
|
-
|
|
63
|
+
const listing = await listRegistrations(name, scope);
|
|
64
|
+
|
|
65
|
+
await emit(options.json, listing, () => render(listing));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The whole answer, gathered. No printing, so a caller can have it as data. */
|
|
69
|
+
export async function listRegistrations(name: string, scope: string): Promise<McpListing> {
|
|
70
|
+
const harnesses: ListedHarness[] = [];
|
|
20
71
|
|
|
21
72
|
for (const harness of HARNESSES) {
|
|
22
73
|
const binary = Bun.which(harness.binary);
|
|
23
74
|
|
|
24
75
|
if (!binary) {
|
|
25
|
-
|
|
76
|
+
harnesses.push({
|
|
77
|
+
id: harness.id,
|
|
78
|
+
label: harness.label,
|
|
79
|
+
installed: false,
|
|
80
|
+
binary: null,
|
|
81
|
+
registered: false,
|
|
82
|
+
documents: [],
|
|
83
|
+
});
|
|
26
84
|
continue;
|
|
27
85
|
}
|
|
28
86
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const line of await documentLines(harness, scope)) print(` ${' '.repeat(14)} ${line}`);
|
|
87
|
+
harnesses.push({
|
|
88
|
+
id: harness.id,
|
|
89
|
+
label: harness.label,
|
|
90
|
+
installed: true,
|
|
91
|
+
binary,
|
|
92
|
+
registered: exists(binary, harness, name),
|
|
93
|
+
documents: await documents(harness, scope),
|
|
94
|
+
});
|
|
38
95
|
}
|
|
96
|
+
|
|
97
|
+
return { name, scope, harnesses };
|
|
39
98
|
}
|
|
40
99
|
|
|
41
|
-
/** One
|
|
42
|
-
async function
|
|
43
|
-
const
|
|
100
|
+
/** One entry per document this harness can hold, saying whether it is current. */
|
|
101
|
+
async function documents(harness: Harness, scope: string): Promise<ListedDocument[]> {
|
|
102
|
+
const listed: ListedDocument[] = [];
|
|
44
103
|
|
|
45
104
|
for (const plan of plannedAssets(harness, scope)) {
|
|
46
105
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
state
|
|
51
|
-
|
|
52
|
-
: style.dim(
|
|
53
|
-
state === 'stale'
|
|
54
|
-
? `${plan.asset.label}: out of date — lanes link mcp add ${harness.id}`
|
|
55
|
-
: `${plan.asset.label}: not installed — lanes link mcp add ${harness.id}`,
|
|
56
|
-
),
|
|
57
|
-
);
|
|
106
|
+
listed.push({
|
|
107
|
+
label: plan.asset.label,
|
|
108
|
+
path: plan.path,
|
|
109
|
+
state: await assetState(plan, await readAsset(plan.asset)),
|
|
110
|
+
});
|
|
58
111
|
} catch (error) {
|
|
59
112
|
// A checkout without `instructions/` — a container image, say. Worth one
|
|
60
113
|
// line rather than a thrown error that hides the registration column.
|
|
61
|
-
|
|
114
|
+
listed.push({
|
|
115
|
+
label: plan.asset.label,
|
|
116
|
+
path: plan.path,
|
|
117
|
+
state: 'unreadable',
|
|
118
|
+
detail: message(error),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return listed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function render(listing: McpListing): void {
|
|
127
|
+
heading(`Registered as ${style.bold(listing.name)}`);
|
|
128
|
+
|
|
129
|
+
for (const harness of listing.harnesses) {
|
|
130
|
+
if (!harness.installed) {
|
|
131
|
+
print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
print(
|
|
136
|
+
` ${harness.label.padEnd(14)} ${
|
|
137
|
+
harness.registered
|
|
138
|
+
? style.green('registered')
|
|
139
|
+
: style.dim(`not registered — lanes link mcp add ${harness.id}`)
|
|
140
|
+
}`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
for (const document of harness.documents) {
|
|
144
|
+
print(` ${' '.repeat(14)} ${documentLine(harness.id, document)}`);
|
|
62
145
|
}
|
|
63
146
|
}
|
|
147
|
+
}
|
|
64
148
|
|
|
65
|
-
|
|
149
|
+
function documentLine(id: string, document: ListedDocument): string {
|
|
150
|
+
switch (document.state) {
|
|
151
|
+
case 'current':
|
|
152
|
+
return style.dim(`${document.label}: `) + style.green('up to date');
|
|
153
|
+
case 'stale':
|
|
154
|
+
return style.dim(`${document.label}: out of date — lanes link mcp add ${id}`);
|
|
155
|
+
case 'missing':
|
|
156
|
+
return style.dim(`${document.label}: not installed — lanes link mcp add ${id}`);
|
|
157
|
+
case 'unreadable':
|
|
158
|
+
return style.dim(`${document.label}: ${document.detail}`);
|
|
159
|
+
}
|
|
66
160
|
}
|
|
67
161
|
|
|
68
162
|
function message(error: unknown): string {
|
package/src/cli/identity.ts
CHANGED
|
@@ -29,9 +29,29 @@ export function pluck(value: unknown, path: string): string | null {
|
|
|
29
29
|
return typeof current === 'string' && current.length > 0 ? current : null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** The header an OAuth or bearer provider's probe carries. */
|
|
33
|
+
async function bearerHeaders(probe: IdentityProbe): Promise<RequestInit> {
|
|
34
|
+
const token = await probe.accessToken();
|
|
35
|
+
return { headers: token ? { authorization: `Bearer ${token}` } : {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
export interface IdentityProbe {
|
|
33
39
|
/** A valid upstream access token, if the provider authenticates. */
|
|
34
40
|
readonly accessToken: () => Promise<string | null>;
|
|
41
|
+
/**
|
|
42
|
+
* Put this connection's credential on a request, whatever method it is.
|
|
43
|
+
*
|
|
44
|
+
* Supplied where `accessToken` cannot answer. A stored `api_key`, `header` or
|
|
45
|
+
* `basic` credential is not a bearer token and `bearerToken` throws for all
|
|
46
|
+
* three — under a comment calling the branch unreachable, which it is on an
|
|
47
|
+
* mcp connector and is not on an http one. The throw was caught by the
|
|
48
|
+
* catch-all below, so an `identity: { kind: http }` block on the commonest
|
|
49
|
+
* custom shape there is — a REST API behind an API key — never worked and
|
|
50
|
+
* never said so: the operator was asked to name the account by hand on every
|
|
51
|
+
* reconnect, and a different answer each time is a new row rather than a
|
|
52
|
+
* repair.
|
|
53
|
+
*/
|
|
54
|
+
readonly authorize?: (request: Request) => Promise<Request>;
|
|
35
55
|
/** Call a capability on the upstream MCP server. */
|
|
36
56
|
readonly callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
37
57
|
/** Ask the connector, for a protocol whose identity is not a URL away. */
|
|
@@ -52,11 +72,15 @@ export async function resolveAccount(
|
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
if (identity.kind === 'http') {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
const send = probe.fetch ?? globalThis.fetch;
|
|
76
|
+
|
|
77
|
+
// `authorize` where the caller supplied one, because it is the same
|
|
78
|
+
// switch the dispatch path uses and knows every method. The token is the
|
|
79
|
+
// fallback rather than the other way round only because the OAuth
|
|
80
|
+
// providers reached here first; both end at the same header for them.
|
|
81
|
+
const response = probe.authorize
|
|
82
|
+
? await send(await probe.authorize(new Request(identity.url)))
|
|
83
|
+
: await send(identity.url, await bearerHeaders(probe));
|
|
60
84
|
if (!response.ok) return null;
|
|
61
85
|
|
|
62
86
|
const body = await response.json();
|
package/src/cli/main.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { connect } from './commands/connect/index.ts';
|
|
2
|
+
import { connectCustom } from './commands/connect/custom/index.ts';
|
|
2
3
|
import {
|
|
3
4
|
attachFile,
|
|
4
5
|
auditTail,
|
|
@@ -29,7 +30,7 @@ import { secretsList, secretsPush, secretsSet } from './commands/secrets.ts';
|
|
|
29
30
|
import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
|
|
30
31
|
import { dispatchOwner } from './dispatch-owner.ts';
|
|
31
32
|
import { update } from './commands/update.ts';
|
|
32
|
-
import { all, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
|
|
33
|
+
import { all, customFlags, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
|
|
33
34
|
import { assertKnownFlags, requireSelection } from './selection.ts';
|
|
34
35
|
import { PROGRAM, USAGE } from './usage.ts';
|
|
35
36
|
import { version } from './version.ts';
|
|
@@ -75,18 +76,41 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
75
76
|
|
|
76
77
|
switch (first) {
|
|
77
78
|
case 'connect':
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
79
|
+
// A nested switch rather than an `if`, so `selection.test.ts` sees
|
|
80
|
+
// `connect custom` when it reads this file for `case` labels: it matches
|
|
81
|
+
// on eight-space indentation, and a command invisible to that check is a
|
|
82
|
+
// command that can default quietly.
|
|
83
|
+
switch (second) {
|
|
84
|
+
case 'custom':
|
|
85
|
+
return connectCustom(rest[0], {
|
|
86
|
+
...global,
|
|
87
|
+
...customFlags(flags, argv),
|
|
88
|
+
id: text(flags, 'id'),
|
|
89
|
+
displayName: text(flags, 'display-name'),
|
|
90
|
+
replace: flags['replace'] === true,
|
|
91
|
+
nonInteractive: flags['non-interactive'] === true,
|
|
92
|
+
acceptBroadScopes: flags['accept-broad-scopes'] === true,
|
|
93
|
+
json,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Not a `case` label, so it adds no row to `SELECTION`: there is no
|
|
97
|
+
// command here to classify, only a usage error.
|
|
98
|
+
case undefined:
|
|
99
|
+
throw new Error(`Usage: ${PROGRAM} connect <provider>`);
|
|
100
|
+
|
|
101
|
+
default:
|
|
102
|
+
return connect(second, {
|
|
103
|
+
...global,
|
|
104
|
+
id: text(flags, 'id'),
|
|
105
|
+
displayName: text(flags, 'display-name'),
|
|
106
|
+
replace: flags['replace'] === true,
|
|
107
|
+
nonInteractive: flags['non-interactive'] === true,
|
|
108
|
+
acceptBroadScopes: flags['accept-broad-scopes'] === true,
|
|
109
|
+
ownClient: flags['own-client'] === true,
|
|
110
|
+
auth: text(flags, 'auth'),
|
|
111
|
+
json,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
90
114
|
|
|
91
115
|
case 'setup':
|
|
92
116
|
if (second !== 'plan' && second !== undefined) {
|
|
@@ -297,7 +321,11 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
297
321
|
return mcpStdio({ ...global, ...(flags['only'] === true ? { only: true } : {}) });
|
|
298
322
|
case 'list':
|
|
299
323
|
case undefined:
|
|
300
|
-
return mcpList({
|
|
324
|
+
return mcpList({
|
|
325
|
+
name: text(flags, 'name'),
|
|
326
|
+
scope: text(flags, 'scope'),
|
|
327
|
+
json: flags['json'] === true,
|
|
328
|
+
});
|
|
301
329
|
default:
|
|
302
330
|
throw new Error(`Unknown: ${PROGRAM} mcp ${second}`);
|
|
303
331
|
}
|
package/src/cli/oauth.ts
CHANGED
|
@@ -66,6 +66,27 @@ export interface OAuthFlowOptions {
|
|
|
66
66
|
* on the way back. What changes is only the address the vendor is told.
|
|
67
67
|
*/
|
|
68
68
|
readonly relayRedirect?: string;
|
|
69
|
+
/**
|
|
70
|
+
* A redirect this machine listens on, named to the vendor exactly as written.
|
|
71
|
+
*
|
|
72
|
+
* The third answer to "where does the browser come back to", and the one the
|
|
73
|
+
* comment above did not anticipate. `relayRedirect` exists for a vendor that
|
|
74
|
+
* will take no loopback address at all; this is for a vendor that takes one
|
|
75
|
+
* happily but matches it *exactly*, port included — so the kernel-chosen port
|
|
76
|
+
* below can never match a URL registered in a console months earlier.
|
|
77
|
+
*
|
|
78
|
+
* That is not a rare shape. It is the reason `providers/github/index.ts`
|
|
79
|
+
* gives for using a pasted token rather than OAuth: "an OAuth App matches its
|
|
80
|
+
* callback URL exactly, including the port, and `connect` listens on a port
|
|
81
|
+
* the kernel picks."
|
|
82
|
+
*
|
|
83
|
+
* The whole URL rather than just a port, because the two must be identical
|
|
84
|
+
* strings and only one of them is ours to choose. A console that accepts
|
|
85
|
+
* `localhost` but not `127.0.0.1` — or insists on a trailing path — decides
|
|
86
|
+
* the spelling, and a manifest that could only name a number would have to
|
|
87
|
+
* guess the rest right.
|
|
88
|
+
*/
|
|
89
|
+
readonly fixedRedirect?: string;
|
|
69
90
|
/** What the completion page names as connected. A provider's display name. */
|
|
70
91
|
readonly connectionLabel?: string;
|
|
71
92
|
/** How long to wait for the operator to finish in the browser. */
|
|
@@ -130,48 +151,80 @@ export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthToke
|
|
|
130
151
|
rejectCode = reject;
|
|
131
152
|
});
|
|
132
153
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
|
|
139
|
-
|
|
140
|
-
const error = url.searchParams.get('error');
|
|
141
|
-
if (error) {
|
|
142
|
-
rejectCode(
|
|
143
|
-
new OAuthError(
|
|
144
|
-
error === 'access_denied'
|
|
145
|
-
? 'Authorization was declined in the browser.'
|
|
146
|
-
: `Authorization failed: ${error}`,
|
|
147
|
-
),
|
|
148
|
-
);
|
|
149
|
-
return failedPage('You can close this tab.');
|
|
150
|
-
}
|
|
154
|
+
// A fixed redirect is only fixed if we listen where it says. Parsed rather
|
|
155
|
+
// than configured separately so the two cannot disagree: the port the vendor
|
|
156
|
+
// was told is by construction the port this binds.
|
|
157
|
+
const fixed = options.fixedRedirect ? new URL(options.fixedRedirect) : undefined;
|
|
158
|
+
const fixedPort = fixed ? Number(fixed.port) : undefined;
|
|
151
159
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
|
|
158
|
-
return failedPage('Unexpected callback.');
|
|
159
|
-
}
|
|
160
|
+
if (fixed && !fixedPort) {
|
|
161
|
+
throw new OAuthError(
|
|
162
|
+
`The redirect "${options.fixedRedirect}" names no port, so there is nothing for this machine to listen on. Register a redirect with an explicit port, such as http://127.0.0.1:8765/callback.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
160
165
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
// Bun.serve throws synchronously when a port is taken. With a kernel-chosen
|
|
167
|
+
// port that cannot happen; with a fixed one it is the ordinary failure — a
|
|
168
|
+
// previous run still holding the socket, or another program on the port the
|
|
169
|
+
// vendor was told about. The raw EADDRINUSE names neither the provider nor
|
|
170
|
+
// the redirect, so it reads as a crash rather than as something to fix.
|
|
171
|
+
const server = ((): ReturnType<typeof Bun.serve> => {
|
|
172
|
+
try {
|
|
173
|
+
return Bun.serve({
|
|
174
|
+
hostname: '127.0.0.1',
|
|
175
|
+
// Zero lets the OS choose, which is right whenever nothing outside this
|
|
176
|
+
// machine names the port. A fixed redirect is exactly the case where
|
|
177
|
+
// something does.
|
|
178
|
+
port: fixedPort ?? 0,
|
|
179
|
+
fetch(request) {
|
|
180
|
+
const url = new URL(request.url);
|
|
181
|
+
if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
|
|
166
182
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
183
|
+
const error = url.searchParams.get('error');
|
|
184
|
+
if (error) {
|
|
185
|
+
rejectCode(
|
|
186
|
+
new OAuthError(
|
|
187
|
+
error === 'access_denied'
|
|
188
|
+
? 'Authorization was declined in the browser.'
|
|
189
|
+
: `Authorization failed: ${error}`,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
return failedPage('You can close this tab.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const returnedState = url.searchParams.get('state');
|
|
196
|
+
if (!returnedState || !stateMatches(state, returnedState)) {
|
|
197
|
+
// A mismatched state means this callback did not come from the
|
|
198
|
+
// request we started. Refuse it rather than redeeming whatever code
|
|
199
|
+
// it carries.
|
|
200
|
+
rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
|
|
201
|
+
return failedPage('Unexpected callback.');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const code = url.searchParams.get('code');
|
|
205
|
+
if (!code) {
|
|
206
|
+
rejectCode(new OAuthError('The callback carried no authorization code.'));
|
|
207
|
+
return failedPage('No code returned.');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resolveCode(code);
|
|
211
|
+
return connectedPage(options.connectionLabel);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
} catch (cause) {
|
|
215
|
+
if (fixedPort) {
|
|
216
|
+
throw new OAuthError(
|
|
217
|
+
`Port ${fixedPort} is already in use, so the callback for this connection cannot be served. The redirect registered with the provider names that port exactly, so it cannot simply be moved — free the port and try again.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
throw cause;
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
171
223
|
|
|
172
224
|
// Where the vendor is told to send the browser. The relay bounces it back
|
|
173
225
|
// here; without one it comes here directly, which is every other provider.
|
|
174
|
-
const redirectUri =
|
|
226
|
+
const redirectUri =
|
|
227
|
+
options.relayRedirect ?? options.fixedRedirect ?? `http://127.0.0.1:${server.port}/callback`;
|
|
175
228
|
|
|
176
229
|
/**
|
|
177
230
|
* The CSRF binding, and — behind a relay — the only way home.
|