@lanes-sh/link 0.1.2 → 0.2.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 +12 -4
- package/package.json +1 -1
- package/src/cli/commands/connect/authorise.ts +8 -1
- package/src/cli/commands/connect/index.ts +7 -8
- package/src/cli/commands/connect/settle.ts +15 -12
- package/src/connectivity/auth/README.md +2 -1
- package/src/connectivity/auth/index.ts +4 -2
- package/src/connectivity/auth/token.ts +93 -0
- package/src/connectivity/manifest/connector.ts +15 -0
- package/src/connectivity/manifest/provider.ts +39 -0
- package/src/connectivity/transports/factory.ts +6 -2
- package/src/connectivity/transports/mcp/index.ts +10 -1
- package/src/providers/github/index.ts +68 -0
- package/src/providers/github/redact.ts +109 -0
- package/src/providers/index.ts +6 -0
- package/src/providers/slack/index.ts +75 -0
- package/src/providers/slack/redact.ts +50 -0
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Lanes Link
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@lanes-sh/link)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/lanes-sh/link/actions/workflows/ci.yml)
|
|
6
|
+
|
|
3
7
|
**One secure endpoint between your AI agents and all your connections, memory, skills, and secrets.**
|
|
4
8
|
|
|
5
9
|
Connect your mail, calendar, files, and notes once, and add the memory and skills that only you
|
|
@@ -93,13 +97,17 @@ One command per account. Run it again to add a second mailbox, a second calendar
|
|
|
93
97
|
| iCloud Drive | `lanes link connect icloud_drive` |
|
|
94
98
|
| Notion | `lanes link connect notion` |
|
|
95
99
|
| Linear | `lanes link connect linear` |
|
|
100
|
+
| GitHub | `lanes link connect github` |
|
|
101
|
+
| Slack | `lanes link connect slack` |
|
|
96
102
|
| Gmail (Google MCP) | `lanes link connect gmail_mcp` |
|
|
97
103
|
| Drive (Google MCP) | `lanes link connect drive_mcp` |
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
together, because one app-specific password covers all three.
|
|
101
|
-
your own: `lanes link connect gmail` authorises against the one Lanes operates, so there is no
|
|
102
|
-
Cloud console to visit
|
|
105
|
+
Three things worth knowing up front. `lanes link connect icloud` sets up Mail, Calendar, and
|
|
106
|
+
Contacts together, because one app-specific password covers all three. Google needs no OAuth client
|
|
107
|
+
of your own: `lanes link connect gmail` authorises against the one Lanes operates, so there is no
|
|
108
|
+
Cloud console to visit — add `--own-client` if you would rather register your own. And GitHub and
|
|
109
|
+
Slack take a token you paste rather than a browser sign-in, because neither will register a client
|
|
110
|
+
for us; for Slack that means creating a Slack app once, which is the one console visit left here.
|
|
103
111
|
|
|
104
112
|
Full guide — what each one gives your agent, what it needs, and adding your own:
|
|
105
113
|
**[docs/connect.md](docs/connect.md)**.
|
package/package.json
CHANGED
|
@@ -21,7 +21,14 @@ import { ensureOAuthApp } from './setup.ts';
|
|
|
21
21
|
* nothing and the manifest has to name its endpoints.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* No longer exported. `#cli` used to reach for this to answer "what token does
|
|
26
|
+
* this manifest authenticate with", which is a question the auth component
|
|
27
|
+
* owns — and answering it here is what let a `bearer` manifest slip through
|
|
28
|
+
* returning null. `bearerTokenAsStored` is the answer now; this builds the
|
|
29
|
+
* provider for the browser flow below, which is the one thing it is for.
|
|
30
|
+
*/
|
|
31
|
+
function oauthProviderFor(
|
|
25
32
|
manifest: ProviderManifest,
|
|
26
33
|
connectionId: string,
|
|
27
34
|
credentials: SecretStore,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createMcpConnector } from '#connectivity/transports';
|
|
2
|
+
import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
|
|
2
3
|
import type { DiscoveredCapability } from '#connectivity';
|
|
3
4
|
import { credentialRefForConnection, WRITE_BUNDLE } from '#connectivity';
|
|
4
5
|
import { ConfigDocument, ensureSetupConnection, repaired } from '../../config-edit.ts';
|
|
@@ -6,7 +7,7 @@ import { emit, print, progress, style } from '../../output.ts';
|
|
|
6
7
|
import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
7
8
|
import { openRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
8
9
|
import { credentialApp, matchesRule, moveCredential, siblingAccountId } from './accounts.ts';
|
|
9
|
-
import { authorise
|
|
10
|
+
import { authorise } from './authorise.ts';
|
|
10
11
|
import { preflight } from './requirements.ts';
|
|
11
12
|
import { ALREADY, NOTHING, renderOutcome, type ConnectOutcome } from './outcome.ts';
|
|
12
13
|
import { nextAfterEdit, publishRuntimeEdit } from '#cli/publish.ts';
|
|
@@ -266,17 +267,15 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
|
|
|
266
267
|
|
|
267
268
|
// MCP is the one kind that does not use the runtime's connector here: it
|
|
268
269
|
// wants the token exactly as just written, without the refresh machinery
|
|
269
|
-
// that `
|
|
270
|
-
//
|
|
270
|
+
// that `bearerToken` wraps around it. Every other kind carries whatever
|
|
271
|
+
// credential it needs from the factory.
|
|
271
272
|
const connector =
|
|
272
273
|
manifest.connector.kind === 'mcp'
|
|
273
274
|
? createMcpConnector({
|
|
274
275
|
endpoint: manifest.connector.endpoint,
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
return tokens?.access_token ?? null;
|
|
279
|
-
},
|
|
276
|
+
...(manifest.connector.headers ? { headers: manifest.connector.headers } : {}),
|
|
277
|
+
accessToken: () =>
|
|
278
|
+
bearerTokenAsStored(manifest, connectionId, runtime.credentials),
|
|
280
279
|
})
|
|
281
280
|
: runtime.connectorFor(providerId, connectionId);
|
|
282
281
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createMcpConnector } from '#connectivity/transports';
|
|
2
|
+
import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
|
|
2
3
|
import type { SecretStore } from '#secrets';
|
|
3
4
|
import type { Config } from '#profile';
|
|
4
5
|
import type { AnyConnector, ProviderManifest } from '#connectivity';
|
|
@@ -6,7 +7,6 @@ import { idFromAccount, resolveAccount } from '../../identity.ts';
|
|
|
6
7
|
import { style } from '../../output.ts';
|
|
7
8
|
import { terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
8
9
|
import { accountSiblings } from './accounts.ts';
|
|
9
|
-
import { oauthProviderFor } from './authorise.ts';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Settle which connection this is, and whose account it belongs to.
|
|
@@ -49,12 +49,10 @@ export async function settleIdentity(input: {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
if (!account) {
|
|
52
|
+
const token = () => bearerTokenAsStored(manifest, provisionalId, runtime.credentials);
|
|
53
|
+
|
|
52
54
|
account = await resolveAccount(manifest, {
|
|
53
|
-
accessToken:
|
|
54
|
-
const provider = oauthProviderFor(manifest, provisionalId, runtime.credentials);
|
|
55
|
-
const tokens = (await provider.tokens()) as { access_token?: string } | undefined;
|
|
56
|
-
return tokens?.access_token ?? null;
|
|
57
|
-
},
|
|
55
|
+
accessToken: token,
|
|
58
56
|
// A protocol that authenticates by username has nothing to GET and no
|
|
59
57
|
// tool to call — it knows, once the server has accepted the login.
|
|
60
58
|
identify: async () =>
|
|
@@ -62,13 +60,18 @@ export async function settleIdentity(input: {
|
|
|
62
60
|
...(manifest.connector.kind === 'mcp'
|
|
63
61
|
? {
|
|
64
62
|
callTool: async (name: string, args: Record<string, unknown>) => {
|
|
63
|
+
// Cast for the same reason `endpoint` already was: the
|
|
64
|
+
// narrowing that reached this branch does not survive into the
|
|
65
|
+
// closure. Named field by field rather than spread, so a future
|
|
66
|
+
// connector field does not silently become a transport option.
|
|
67
|
+
const mcp = manifest.connector as {
|
|
68
|
+
endpoint: string;
|
|
69
|
+
headers?: Record<string, string>;
|
|
70
|
+
};
|
|
65
71
|
const connector = createMcpConnector({
|
|
66
|
-
endpoint:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const tokens = (await provider.tokens()) as { access_token?: string } | undefined;
|
|
70
|
-
return tokens?.access_token ?? null;
|
|
71
|
-
},
|
|
72
|
+
endpoint: mcp.endpoint,
|
|
73
|
+
...(mcp.headers ? { headers: mcp.headers } : {}),
|
|
74
|
+
accessToken: token,
|
|
72
75
|
});
|
|
73
76
|
return connector.invoke({ name, inputSchema: {}, description: '' } as never, args, {
|
|
74
77
|
manifest,
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
One folder per method. Each owns both halves of its job: `resolve*` turns the
|
|
4
4
|
stored secret into a `ResolvedCredential`, and `attach*` puts that shape on an
|
|
5
5
|
outbound request. `resolve.ts` and `authorize.ts` are the only files that know
|
|
6
|
-
the whole set.
|
|
6
|
+
the whole set — plus `token.ts`, which answers the narrower question a
|
|
7
|
+
transport asks when it has a token to send and no request to attach it to.
|
|
7
8
|
|
|
8
9
|
| Folder | `auth.kind` | What is stored |
|
|
9
10
|
|---|---|---|
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One folder per method, each owning both halves of its job: turning the stored
|
|
5
5
|
* secret into a resolved shape (`resolve*`) and putting that shape on an
|
|
6
|
-
* outbound request (`attach*`). The
|
|
7
|
-
*
|
|
6
|
+
* outbound request (`attach*`). The dispatchers here are the only files that
|
|
7
|
+
* know the whole set: `resolve.ts` and `authorize.ts` for anything HTTP-shaped,
|
|
8
|
+
* and `token.ts` for a transport that takes a bare token instead of a request.
|
|
8
9
|
*
|
|
9
10
|
* This is the axis the manifest's `auth:` block selects, and it is deliberately
|
|
10
11
|
* independent of `../transports/` — which is why iCloud can speak IMAP with a
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
export { credentialResolver, type ResolvedCredential } from './resolve.ts';
|
|
16
17
|
export { requestAuthorizer } from './authorize.ts';
|
|
17
18
|
export { basicCredential } from './basic/index.ts';
|
|
19
|
+
export { bearerToken, bearerTokenAsStored } from './token.ts';
|
|
18
20
|
export {
|
|
19
21
|
CredentialOAuthProvider,
|
|
20
22
|
clearUpstreamTokens,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type ProviderManifest } from '#connectivity';
|
|
2
|
+
import type { ProviderRegistry } from '#registry';
|
|
3
|
+
import type { SecretStore } from '#secrets';
|
|
4
|
+
import { credentialResolver } from './resolve.ts';
|
|
5
|
+
import { CredentialOAuthProvider } from './oauth-authcode/provider.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The bearer token for one connection, whichever way the provider came by it.
|
|
9
|
+
*
|
|
10
|
+
* An mcp connector sends `Authorization: Bearer <token>` and nothing else, so
|
|
11
|
+
* it takes a token rather than a `Request` to authorise — the same shape
|
|
12
|
+
* `basicCredential` has for IMAP and DAV, and for the same reason: a transport
|
|
13
|
+
* that has no `Request` to hand an authorizer gets its credential as a bound
|
|
14
|
+
* closure instead.
|
|
15
|
+
*
|
|
16
|
+
* What lives here that does not live in `./bearer/` is the *dispatch*. Two
|
|
17
|
+
* unrelated arrangements produce a bearer token — an OAuth access token
|
|
18
|
+
* exchanged on every use, and a long-lived one the operator pasted — and the
|
|
19
|
+
* caller does not care which, only the manifest does. Putting that choice in
|
|
20
|
+
* the bearer folder would make the folder that owns one method know about
|
|
21
|
+
* another; putting it in `oauth-authcode/` would make the OAuth folder answer
|
|
22
|
+
* for a token no OAuth flow ever produced.
|
|
23
|
+
*
|
|
24
|
+
* Before this existed, every caller asked `CredentialOAuthProvider` for the
|
|
25
|
+
* token, and a manifest declaring `bearer` got `null` back rather than an
|
|
26
|
+
* error: the provider's tokens ref is `<provider>/<connection>`, byte-identical
|
|
27
|
+
* to the ref a pasted token derives, so it read the token, failed to parse it
|
|
28
|
+
* as a JSON blob, and returned undefined by design. The connection then went
|
|
29
|
+
* upstream with no `Authorization` header at all.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** One manifest, dressed as a registry, so the resolver needs no lookup. */
|
|
33
|
+
const only = (manifest: ProviderManifest): ProviderRegistry =>
|
|
34
|
+
({ manifest: () => manifest }) as unknown as ProviderRegistry;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Throws where the manifest declares a credential and none is stored:
|
|
38
|
+
* `credentialResolver` already says which ref is empty and which command fills
|
|
39
|
+
* it, which beats a `null` that reaches the server as a missing header.
|
|
40
|
+
*/
|
|
41
|
+
export async function bearerToken(
|
|
42
|
+
manifest: ProviderManifest,
|
|
43
|
+
connectionId: string,
|
|
44
|
+
secrets: SecretStore,
|
|
45
|
+
): Promise<string | null> {
|
|
46
|
+
const resolved = await credentialResolver(only(manifest), secrets)(manifest.id, connectionId);
|
|
47
|
+
|
|
48
|
+
switch (resolved.kind) {
|
|
49
|
+
case 'none':
|
|
50
|
+
return null;
|
|
51
|
+
case 'oauth':
|
|
52
|
+
return resolved.accessToken;
|
|
53
|
+
case 'bearer':
|
|
54
|
+
return resolved.token;
|
|
55
|
+
default:
|
|
56
|
+
// Unreachable: `defineProvider` refuses every other kind on an mcp
|
|
57
|
+
// connector. Kept so the guard and this switch cannot drift apart
|
|
58
|
+
// silently — if one is ever relaxed, the other says so.
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Provider "${manifest.id}" resolves to a "${resolved.kind}" credential, which cannot be sent as a bearer token.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The same token, read exactly as stored, without the refresh machinery.
|
|
67
|
+
*
|
|
68
|
+
* For `connect`: it has just written the token and wants that one, not a
|
|
69
|
+
* refreshed one. Going through `bearerToken` there would populate the process's
|
|
70
|
+
* access-token cache under the provisional connection id — `linear.pending`,
|
|
71
|
+
* a key naming a connection that will not exist a moment later — and could
|
|
72
|
+
* spend a network round trip re-exchanging a token written seconds ago.
|
|
73
|
+
*
|
|
74
|
+
* Identical to `bearerToken` for every non-OAuth kind, because a stored token
|
|
75
|
+
* has no other reading.
|
|
76
|
+
*/
|
|
77
|
+
export async function bearerTokenAsStored(
|
|
78
|
+
manifest: ProviderManifest,
|
|
79
|
+
connectionId: string,
|
|
80
|
+
secrets: SecretStore,
|
|
81
|
+
): Promise<string | null> {
|
|
82
|
+
if (manifest.auth.kind !== 'oauth') return bearerToken(manifest, connectionId, secrets);
|
|
83
|
+
|
|
84
|
+
const provider = new CredentialOAuthProvider({
|
|
85
|
+
manifest,
|
|
86
|
+
connectionId,
|
|
87
|
+
credentials: secrets,
|
|
88
|
+
scopes: manifest.auth.scopes,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const tokens = (await provider.tokens()) as { access_token?: string } | undefined;
|
|
92
|
+
return tokens?.access_token ?? null;
|
|
93
|
+
}
|
|
@@ -17,6 +17,21 @@ import { z } from 'zod';
|
|
|
17
17
|
export const mcpConnectorSchema = z.object({
|
|
18
18
|
kind: z.literal('mcp'),
|
|
19
19
|
endpoint: z.url(),
|
|
20
|
+
/**
|
|
21
|
+
* Sent on every request to this server, discovery included.
|
|
22
|
+
*
|
|
23
|
+
* The `http` connector filters what it exposes with `operations` above,
|
|
24
|
+
* because it reads a document listing everything the API can do. An mcp
|
|
25
|
+
* server decides that for itself and answers `tools/list` with whatever it
|
|
26
|
+
* chose — so where a vendor makes the choice configurable, the configuration
|
|
27
|
+
* is a header they define. GitHub's `X-MCP-Toolsets` is the case in hand: the
|
|
28
|
+
* default is broad and `all` is broader, and this is the only way to ask for
|
|
29
|
+
* less.
|
|
30
|
+
*
|
|
31
|
+
* Never `Authorization` — that one belongs to `auth:`, and the check in
|
|
32
|
+
* `./provider.ts` refuses it rather than letting the two disagree silently.
|
|
33
|
+
*/
|
|
34
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
20
35
|
});
|
|
21
36
|
|
|
22
37
|
/**
|
|
@@ -196,6 +196,45 @@ export function defineProvider(input: unknown): ProviderManifest {
|
|
|
196
196
|
);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
if (manifest.connector.kind === 'mcp') {
|
|
200
|
+
const auth = manifest.auth;
|
|
201
|
+
|
|
202
|
+
// The transport sends exactly one header, `Authorization: Bearer <token>`,
|
|
203
|
+
// because that is what the MCP specification says a client sends. Every
|
|
204
|
+
// other token kind puts the secret somewhere the transport has nowhere to
|
|
205
|
+
// put it: `api_key` in a query string or a named header, `header` under a
|
|
206
|
+
// name of its own, `basic` in a different scheme entirely. Such a manifest
|
|
207
|
+
// validates and then connects *unauthenticated* — no error, an empty tool
|
|
208
|
+
// list, and nothing to read that says why.
|
|
209
|
+
if (auth.kind !== 'none' && auth.kind !== 'oauth' && auth.kind !== 'bearer') {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Provider "${manifest.id}": an mcp connector authenticates with "Authorization: Bearer", so its auth must be "none", "oauth", or "bearer" — not "${auth.kind}". There is nowhere else on the request for the transport to put a credential.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Same failure, one field further in. `bearer` may rename its header, and
|
|
216
|
+
// `resolveBearer` honours that — but the mcp transport does not read the
|
|
217
|
+
// resolved credential at all, only the token, so a renamed header would be
|
|
218
|
+
// silently ignored and the token sent under `Authorization` regardless.
|
|
219
|
+
if (auth.kind === 'bearer' && auth.header) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Provider "${manifest.id}": an mcp connector always sends its token as "Authorization: Bearer", so auth.header ("${auth.header}") cannot be honoured. Remove it, or reach this service with an http connector.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The third spelling of the same collision. Connector headers are for what
|
|
226
|
+
// the *server* offers as configuration; the credential is the auth block's,
|
|
227
|
+
// and a manifest setting both would have one quietly overwrite the other
|
|
228
|
+
// depending on which the transport merged last.
|
|
229
|
+
for (const name of Object.keys(manifest.connector.headers ?? {})) {
|
|
230
|
+
if (name.toLowerCase() === 'authorization') {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Provider "${manifest.id}": connector.headers may not set "${name}" — the credential comes from auth, and setting both would leave which one is sent up to merge order.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
199
238
|
const names = new Set<string>();
|
|
200
239
|
for (const bundle of manifest.bundles ?? []) {
|
|
201
240
|
if (names.has(bundle.name)) {
|
|
@@ -2,7 +2,7 @@ import type { AnyConnector, ProviderDefinition, ProviderManifest } from '#connec
|
|
|
2
2
|
import type { SecretStore } from '#secrets';
|
|
3
3
|
import type { ProviderRegistry } from '#registry';
|
|
4
4
|
import { basicCredential } from '#connectivity/auth/basic/index.ts';
|
|
5
|
-
import {
|
|
5
|
+
import { bearerToken } from '#connectivity/auth/token.ts';
|
|
6
6
|
import { createCompositeConnector } from './composite/index.ts';
|
|
7
7
|
import { createDavConnector } from './dav/index.ts';
|
|
8
8
|
import { createFsConnector } from './fs/index.ts';
|
|
@@ -114,7 +114,11 @@ function build(
|
|
|
114
114
|
case 'mcp':
|
|
115
115
|
return createMcpConnector({
|
|
116
116
|
endpoint: manifest.connector.endpoint,
|
|
117
|
-
|
|
117
|
+
...(manifest.connector.headers ? { headers: manifest.connector.headers } : {}),
|
|
118
|
+
// Not `resolveUpstreamToken` directly: that one answers only for OAuth,
|
|
119
|
+
// and returns null for a provider whose token the operator pasted —
|
|
120
|
+
// which reaches the server as a missing header rather than an error.
|
|
121
|
+
accessToken: () => bearerToken(manifest, connectionId, options.credentials),
|
|
118
122
|
});
|
|
119
123
|
|
|
120
124
|
case 'imap':
|
|
@@ -24,6 +24,8 @@ export interface McpConnectorOptions {
|
|
|
24
24
|
readonly endpoint: string;
|
|
25
25
|
/** Supplies the bearer token for an upstream call; refreshes if needed. */
|
|
26
26
|
readonly accessToken: () => Promise<string | null>;
|
|
27
|
+
/** Whatever the manifest's connector declares, sent on every request. */
|
|
28
|
+
readonly headers?: Record<string, string> | undefined;
|
|
27
29
|
readonly fetch?: typeof globalThis.fetch;
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -127,7 +129,14 @@ export function createMcpConnector(options: McpConnectorOptions): Connector {
|
|
|
127
129
|
|
|
128
130
|
const transport = new StreamableHTTPClientTransport(new URL(options.endpoint), {
|
|
129
131
|
requestInit: {
|
|
130
|
-
headers
|
|
132
|
+
// The declared headers first, so the credential cannot be displaced by
|
|
133
|
+
// one. `defineProvider` already refuses a declared `Authorization`, and
|
|
134
|
+
// this order means a manifest loaded some other way fails safe rather
|
|
135
|
+
// than sending someone else's header in its place.
|
|
136
|
+
headers: {
|
|
137
|
+
...(options.headers ?? {}),
|
|
138
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
139
|
+
},
|
|
131
140
|
},
|
|
132
141
|
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
133
142
|
} as never);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineProvider } from '#connectivity';
|
|
2
|
+
import { GITHUB_REDACT } from './redact.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GitHub, through the server GitHub runs.
|
|
6
|
+
*
|
|
7
|
+
* Not OAuth, and not for want of trying. GitHub's remote MCP server does not
|
|
8
|
+
* offer Dynamic Client Registration — the thing that makes Notion and Linear
|
|
9
|
+
* cost fifteen lines — so there is no client to register ourselves as. A client
|
|
10
|
+
* of the operator's own is the documented fallback everywhere else, and it
|
|
11
|
+
* fails here on a detail: an OAuth App matches its callback URL exactly,
|
|
12
|
+
* including the port, and `connect` listens on a port the kernel picks. What is
|
|
13
|
+
* left is the credential GitHub does issue for exactly this — a token you
|
|
14
|
+
* generate once and paste. See ADR-033.
|
|
15
|
+
*
|
|
16
|
+
* The toolsets header is the whole reason `connector.headers` exists. GitHub
|
|
17
|
+
* serves a different tool list per toolset and `all` is far more than an agent
|
|
18
|
+
* reasons over; this asks for the ones an agent working in a repository
|
|
19
|
+
* actually uses. It is one string to change, and `docs/detailed/setup/github.md`
|
|
20
|
+
* records the read-only variant for someone who wants a narrower connection.
|
|
21
|
+
*/
|
|
22
|
+
export const github = defineProvider({
|
|
23
|
+
id: 'github',
|
|
24
|
+
name: 'GitHub',
|
|
25
|
+
description: 'Repositories, issues, pull requests, and workflow runs, via GitHub\'s official MCP server.',
|
|
26
|
+
connector: {
|
|
27
|
+
kind: 'mcp',
|
|
28
|
+
endpoint: 'https://api.githubcopilot.com/mcp/',
|
|
29
|
+
headers: { 'X-MCP-Toolsets': 'context,repos,issues,pull_requests,actions,labels' },
|
|
30
|
+
},
|
|
31
|
+
auth: { kind: 'bearer' },
|
|
32
|
+
// GitHub's own MCP server answers `get_me`, so a tool identity would work.
|
|
33
|
+
// This asks the REST API instead, for one reason: it is a plain GET that
|
|
34
|
+
// costs no MCP handshake, and `connect` runs it before discovery — so when
|
|
35
|
+
// the token is wrong, the thing that fails is the cheap call rather than the
|
|
36
|
+
// expensive one.
|
|
37
|
+
identity: { kind: 'http', url: 'https://api.github.com/user', field: 'login' },
|
|
38
|
+
redact: GITHUB_REDACT,
|
|
39
|
+
setup: {
|
|
40
|
+
summary:
|
|
41
|
+
'GitHub issues a fine-grained personal access token for this. There is no OAuth app to register: ' +
|
|
42
|
+
'GitHub\'s MCP server does not support the dynamic registration Notion and Linear use, and an OAuth ' +
|
|
43
|
+
'app of your own would need a fixed callback port, which this CLI does not have. You are asked once.',
|
|
44
|
+
docs: 'docs/detailed/setup/github.md',
|
|
45
|
+
docs_url: 'https://github.com/settings/personal-access-tokens',
|
|
46
|
+
steps: [
|
|
47
|
+
'Open https://github.com/settings/personal-access-tokens and choose "Generate new token".',
|
|
48
|
+
'Name it "Lanes Link" — the name is how you revoke this one later without touching your other tokens — and set an expiry you are willing to renew.',
|
|
49
|
+
'Resource owner: yourself, or the organisation whose repositories you want reachable. An organisation may require an owner to approve the token before it works.',
|
|
50
|
+
'Repository access: only the repositories you want an agent to see. "All repositories" is the setting people regret.',
|
|
51
|
+
'Permissions, matching the toolsets this connects: Contents (read), Metadata (read, added for you), Issues (read and write), Pull requests (read and write), Actions (read). Add Administration or Workflows only if you know you need them.',
|
|
52
|
+
'Generate, then copy the token. GitHub shows it once, and it starts with github_pat_.',
|
|
53
|
+
'When it expires, generate another and run: lanes link connect github --replace.',
|
|
54
|
+
],
|
|
55
|
+
troubleshooting:
|
|
56
|
+
'GitHub refused the token. The usual causes are an expired token, a repository the token was not granted, ' +
|
|
57
|
+
'or an organisation token still waiting on an owner\'s approval. Generate a new one at ' +
|
|
58
|
+
'https://github.com/settings/personal-access-tokens and re-run: lanes link connect github --replace.',
|
|
59
|
+
prompts: [
|
|
60
|
+
{
|
|
61
|
+
key: 'token',
|
|
62
|
+
label: 'GitHub personal access token',
|
|
63
|
+
secret: true,
|
|
64
|
+
scope: 'connection' as const,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What survives into the audit log when GitHub is written to.
|
|
3
|
+
*
|
|
4
|
+
* The default withholds every value, which is right for a server whose
|
|
5
|
+
* capabilities we did not author and cannot know the shape of. It is wrong for
|
|
6
|
+
* the writes: "an issue was edited" without saying which issue, in which
|
|
7
|
+
* repository, or into what state is a record of nothing.
|
|
8
|
+
*
|
|
9
|
+
* The line drawn is the one Gmail and Drive draw. Identifiers and flags are
|
|
10
|
+
* kept — `owner`, `repo`, the number, the method, the state, the labels and
|
|
11
|
+
* reviewers, the merge method. The user's own words are withheld: `title`,
|
|
12
|
+
* `body`, `commit_message`. A log that said which pull request was merged and
|
|
13
|
+
* by what method has answered the question; one that also quoted the commit
|
|
14
|
+
* message has started keeping a copy of the work.
|
|
15
|
+
*
|
|
16
|
+
* `assignees` and `reviewers` are kept, and that departs from withholding
|
|
17
|
+
* people the way `drive.permissions.create` keeps `emailAddress`: assigning
|
|
18
|
+
* somebody *is* the change, so a log that cannot say to whom has failed at its
|
|
19
|
+
* one question.
|
|
20
|
+
*
|
|
21
|
+
* Two caveats worth stating rather than discovering.
|
|
22
|
+
*
|
|
23
|
+
* A proxied server's capabilities are discovered, not declared, so the argument
|
|
24
|
+
* names below come from GitHub's published tool documentation rather than from
|
|
25
|
+
* a schema in this repository. `cli/tools.test.ts` checks these names for every
|
|
26
|
+
* `http` provider and cannot check them here — there is nothing local to check
|
|
27
|
+
* against. A name that is wrong, or that GitHub renames later, fails the way
|
|
28
|
+
* that test exists to prevent: silently, with the value withheld and the log
|
|
29
|
+
* reading exactly as it does when redaction is working. `lanes link doctor`
|
|
30
|
+
* reporting capability drift is the signal that this list wants re-reading.
|
|
31
|
+
*
|
|
32
|
+
* Reads are absent on purpose. `search_issues` takes a `query`, which is a
|
|
33
|
+
* question somebody asked rather than a record of something that happened —
|
|
34
|
+
* the same ground Gmail withholds `q` on.
|
|
35
|
+
*/
|
|
36
|
+
export const GITHUB_REDACT: Record<string, string[]> = {
|
|
37
|
+
// `method` is the verb — create, update, close — and without it the entry
|
|
38
|
+
// says an issue was written to and not what was done to it.
|
|
39
|
+
issue_write: [
|
|
40
|
+
'owner',
|
|
41
|
+
'repo',
|
|
42
|
+
'issue_number',
|
|
43
|
+
'method',
|
|
44
|
+
'state',
|
|
45
|
+
'state_reason',
|
|
46
|
+
'labels',
|
|
47
|
+
'assignees',
|
|
48
|
+
'milestone',
|
|
49
|
+
'type',
|
|
50
|
+
'duplicate_of',
|
|
51
|
+
],
|
|
52
|
+
add_issue_comment: ['owner', 'repo', 'issue_number', 'comment_id', 'reaction'],
|
|
53
|
+
sub_issue_write: [
|
|
54
|
+
'owner',
|
|
55
|
+
'repo',
|
|
56
|
+
'issue_number',
|
|
57
|
+
'method',
|
|
58
|
+
'sub_issue_id',
|
|
59
|
+
'replace_parent',
|
|
60
|
+
'after_id',
|
|
61
|
+
'before_id',
|
|
62
|
+
],
|
|
63
|
+
// No `title`: a branch name says which change this is, and the title is the
|
|
64
|
+
// author's summary of it.
|
|
65
|
+
create_pull_request: [
|
|
66
|
+
'owner',
|
|
67
|
+
'repo',
|
|
68
|
+
'head',
|
|
69
|
+
'base',
|
|
70
|
+
'draft',
|
|
71
|
+
'reviewers',
|
|
72
|
+
'maintainer_can_modify',
|
|
73
|
+
],
|
|
74
|
+
update_pull_request: [
|
|
75
|
+
'owner',
|
|
76
|
+
'repo',
|
|
77
|
+
'pullNumber',
|
|
78
|
+
'base',
|
|
79
|
+
'state',
|
|
80
|
+
'draft',
|
|
81
|
+
'reviewers',
|
|
82
|
+
'maintainer_can_modify',
|
|
83
|
+
],
|
|
84
|
+
merge_pull_request: ['owner', 'repo', 'pullNumber', 'merge_method'],
|
|
85
|
+
// `event` is the one that matters: approving is a different act from
|
|
86
|
+
// commenting, and this is the only place that distinction is recorded.
|
|
87
|
+
pull_request_review_write: [
|
|
88
|
+
'owner',
|
|
89
|
+
'repo',
|
|
90
|
+
'pullNumber',
|
|
91
|
+
'method',
|
|
92
|
+
'event',
|
|
93
|
+
'commitID',
|
|
94
|
+
'threadId',
|
|
95
|
+
],
|
|
96
|
+
add_comment_to_pending_review: [
|
|
97
|
+
'owner',
|
|
98
|
+
'repo',
|
|
99
|
+
'pullNumber',
|
|
100
|
+
'path',
|
|
101
|
+
'line',
|
|
102
|
+
'startLine',
|
|
103
|
+
'side',
|
|
104
|
+
'startSide',
|
|
105
|
+
'subjectType',
|
|
106
|
+
],
|
|
107
|
+
add_reply_to_pull_request_comment: ['owner', 'repo', 'pullNumber', 'commentId', 'reaction'],
|
|
108
|
+
update_pull_request_branch: ['owner', 'repo', 'pullNumber', 'expectedHeadSha'],
|
|
109
|
+
};
|
package/src/providers/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ProviderDefinition, ProviderManifest } from '#connectivity';
|
|
|
2
2
|
import { calendar } from './google/calendar/index.ts';
|
|
3
3
|
import { contacts } from './google/contacts/index.ts';
|
|
4
4
|
import { docs } from './google/docs/index.ts';
|
|
5
|
+
import { github } from './github/index.ts';
|
|
5
6
|
import { drive } from './google/drive/index.ts';
|
|
6
7
|
import { driveMcp } from './google/drive-mcp/index.ts';
|
|
7
8
|
import { gmail } from './google/gmail/index.ts';
|
|
@@ -14,6 +15,7 @@ import { icloudDrive } from './icloud/drive/index.ts';
|
|
|
14
15
|
import { icloudMail } from './icloud/mail/index.ts';
|
|
15
16
|
import { linear } from './linear/index.ts';
|
|
16
17
|
import { notion } from './notion/index.ts';
|
|
18
|
+
import { slack } from './slack/index.ts';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Every provider, in one list.
|
|
@@ -48,6 +50,8 @@ import { notion } from './notion/index.ts';
|
|
|
48
50
|
export const PROVIDERS: readonly (ProviderManifest | ProviderDefinition)[] = [
|
|
49
51
|
notion,
|
|
50
52
|
linear,
|
|
53
|
+
github,
|
|
54
|
+
slack,
|
|
51
55
|
gmail,
|
|
52
56
|
drive,
|
|
53
57
|
sheets,
|
|
@@ -88,6 +92,8 @@ export {
|
|
|
88
92
|
tasks,
|
|
89
93
|
} from './google/index.ts';
|
|
90
94
|
export { icloudCalendar, icloudContacts, icloudDrive, icloudMail } from './icloud/index.ts';
|
|
95
|
+
export { github } from './github/index.ts';
|
|
91
96
|
export { linear } from './linear/index.ts';
|
|
92
97
|
export { notion } from './notion/index.ts';
|
|
98
|
+
export { slack } from './slack/index.ts';
|
|
93
99
|
export { SCOPE_MEANINGS, type ScopeMeaning } from './scopes.ts';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineProvider } from '#connectivity';
|
|
2
|
+
import { SLACK_REDACT } from './redact.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Slack, through the server Slack runs.
|
|
6
|
+
*
|
|
7
|
+
* The only provider here whose vendor has closed every door but one. Slack's
|
|
8
|
+
* MCP server does not offer Dynamic Client Registration — their documentation
|
|
9
|
+
* says so outright — and a client of your own cannot work either: Slack
|
|
10
|
+
* requires an HTTPS redirect URI, and `connect` listens on `http://127.0.0.1`
|
|
11
|
+
* on a port the kernel picks. There is no proxy, tunnel, or flag that makes a
|
|
12
|
+
* loopback listener HTTPS. A broker would answer it and `defineProvider`
|
|
13
|
+
* refuses one on an mcp connector, because the SDK owns that exchange.
|
|
14
|
+
*
|
|
15
|
+
* What is left is the user token the Slack app mints when you install it, sent
|
|
16
|
+
* as `Authorization: Bearer`. Slack supports that path deliberately; it is the
|
|
17
|
+
* arrangement their own docs describe for a client that cannot register. See
|
|
18
|
+
* ADR-033.
|
|
19
|
+
*
|
|
20
|
+
* Unlike GitHub, this does cost a console visit — creating a Slack app is the
|
|
21
|
+
* only way to get a user token at all, and no amount of implementation work on
|
|
22
|
+
* this side removes it. The setup block is therefore longer than any other here
|
|
23
|
+
* except Google's, and that is the honest shape of it.
|
|
24
|
+
*/
|
|
25
|
+
export const slack = defineProvider({
|
|
26
|
+
id: 'slack',
|
|
27
|
+
name: 'Slack',
|
|
28
|
+
description: 'Messages, threads, channels, files, and canvases, via Slack\'s official MCP server.',
|
|
29
|
+
connector: { kind: 'mcp', endpoint: 'https://mcp.slack.com/mcp' },
|
|
30
|
+
auth: { kind: 'bearer' },
|
|
31
|
+
/**
|
|
32
|
+
* The person, not the workspace, and the distinction is load-bearing.
|
|
33
|
+
*
|
|
34
|
+
* `settleIdentity` matches a resolved account against existing connections
|
|
35
|
+
* to decide whether this is a reconnect or a new account. Labelled by
|
|
36
|
+
* workspace, a second person's token in the same workspace would look like a
|
|
37
|
+
* reconnect of the first and overwrite their credential. `auth.test` returns
|
|
38
|
+
* both; `user` is the one that is unique per token.
|
|
39
|
+
*
|
|
40
|
+
* Slack answers a bad token with HTTP 200 and `{ok: false}`, so a wrong token
|
|
41
|
+
* reaches `connect`'s "which account is this?" fallback rather than a clear
|
|
42
|
+
* refusal. Discovery fails loudly one step later, which is where the real
|
|
43
|
+
* error is.
|
|
44
|
+
*/
|
|
45
|
+
identity: { kind: 'http', url: 'https://slack.com/api/auth.test', field: 'user' },
|
|
46
|
+
redact: SLACK_REDACT,
|
|
47
|
+
setup: {
|
|
48
|
+
summary:
|
|
49
|
+
'Slack needs an app of its own — there is no personal access token and no way to register ' +
|
|
50
|
+
'automatically, because Slack requires an HTTPS callback and this CLI listens on localhost. ' +
|
|
51
|
+
'You create the app once, install it to your workspace, and paste the user token it mints.',
|
|
52
|
+
docs: 'docs/detailed/setup/slack.md',
|
|
53
|
+
docs_url: 'https://api.slack.com/apps',
|
|
54
|
+
steps: [
|
|
55
|
+
'Open https://api.slack.com/apps and choose "Create New App" → "From scratch". Name it "Lanes Link" and pick the workspace.',
|
|
56
|
+
'Open "OAuth & Permissions" and scroll to "Scopes". Add these under USER TOKEN SCOPES — not Bot Token Scopes; the MCP server reads the user token: search:read.public, search:read.private, search:read.im, search:read.mpim, search:read.users, search:read.files, channels:history, groups:history, im:history, mpim:history, channels:read, groups:read, mpim:read, users:read, chat:write, files:read.',
|
|
57
|
+
'For reactions, canvases, or creating channels, add reactions:write, canvases:read, canvases:write, or channels:write as well. Those tools are listed either way and fail at call time without the scope.',
|
|
58
|
+
'Scroll up and choose "Install to Workspace", then approve. A Slack admin may have to approve it for you.',
|
|
59
|
+
'Copy the "User OAuth Token" from the same page. It starts with xoxp- — not the bot token, which starts with xoxb- and will not work here.',
|
|
60
|
+
'The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, run: lanes link connect slack --replace.',
|
|
61
|
+
],
|
|
62
|
+
troubleshooting:
|
|
63
|
+
'Slack refused the token. The usual causes are a bot token (xoxb-) pasted where the user token (xoxp-) belongs, ' +
|
|
64
|
+
'a scope missing from USER TOKEN SCOPES, or an app that was reinstalled since — reinstalling mints a new token. ' +
|
|
65
|
+
'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --replace.',
|
|
66
|
+
prompts: [
|
|
67
|
+
{
|
|
68
|
+
key: 'token',
|
|
69
|
+
label: 'Slack user OAuth token (xoxp-…)',
|
|
70
|
+
secret: true,
|
|
71
|
+
scope: 'connection' as const,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What survives into the audit log when Slack is written to.
|
|
3
|
+
*
|
|
4
|
+
* The line is the one Gmail draws for mail, because it is the same object: a
|
|
5
|
+
* message someone wrote. Where it went is recorded, what it said is not.
|
|
6
|
+
* `channel_id` is an identifier, `thread_ts` says which conversation, and
|
|
7
|
+
* `message` is the whole of the content — a log that quoted it would be a
|
|
8
|
+
* second copy of everybody's Slack, held somewhere nobody expects one.
|
|
9
|
+
*
|
|
10
|
+
* Keys are the *shortened* names. `shortenName` strips a redundant provider
|
|
11
|
+
* prefix, so the upstream `slack_send_message` is `send_message` here, in
|
|
12
|
+
* policy, and in the audit log. Keying this block on the upstream name would
|
|
13
|
+
* match nothing and withhold everything, silently.
|
|
14
|
+
*
|
|
15
|
+
* The same caveat as GitHub's, and it is worth repeating rather than
|
|
16
|
+
* cross-referencing: a proxied server's capabilities are discovered, not
|
|
17
|
+
* declared, so `cli/tools.test.ts` cannot check these names against a local
|
|
18
|
+
* schema the way it does for every `http` provider. They were read off the
|
|
19
|
+
* tool schemas Slack's server actually publishes rather than guessed, but a
|
|
20
|
+
* rename upstream fails the way that test exists to prevent — the value is
|
|
21
|
+
* withheld and the log reads exactly as it does when redaction is working.
|
|
22
|
+
* `lanes link doctor` reporting capability drift is the signal to re-read this.
|
|
23
|
+
*
|
|
24
|
+
* Some entries name tools the default scope set cannot call. That is
|
|
25
|
+
* deliberate: Slack lists every tool regardless of scope and refuses at call
|
|
26
|
+
* time, so someone who later adds `reactions:write` or `canvases:write` finds
|
|
27
|
+
* the log already correct rather than discovering it is not.
|
|
28
|
+
*/
|
|
29
|
+
export const SLACK_REDACT: Record<string, string[]> = {
|
|
30
|
+
// Everything except the message. `reply_broadcast` is kept because "replied
|
|
31
|
+
// in a thread" and "replied in a thread and pushed it to the channel" are
|
|
32
|
+
// different acts, and only this argument distinguishes them.
|
|
33
|
+
send_message: ['channel_id', 'thread_ts', 'reply_broadcast', 'unfurl_app_links', 'draft_id'],
|
|
34
|
+
send_message_draft: ['channel_id', 'thread_ts'],
|
|
35
|
+
schedule_message: ['channel_id', 'post_at', 'thread_ts', 'reply_broadcast'],
|
|
36
|
+
// The emoji is kept. It is a name from a fixed vocabulary rather than
|
|
37
|
+
// something anyone typed — the same reading that lets Gmail keep label ids —
|
|
38
|
+
// and an entry saying a message was reacted to without saying how records
|
|
39
|
+
// nothing anyone would look for.
|
|
40
|
+
add_reaction: ['channel_id', 'message_ts', 'emoji'],
|
|
41
|
+
// Nothing. A canvas has no identifier until Slack answers with one, so both
|
|
42
|
+
// arguments are the document: `title` is the author's words and `content` is
|
|
43
|
+
// the whole of it. This is `gmail.send_message`'s position, reached the same
|
|
44
|
+
// way — there is no identifier here to keep, so keeping anything would mean
|
|
45
|
+
// keeping content.
|
|
46
|
+
create_canvas: [],
|
|
47
|
+
// `sections` is withheld along with `content`: each entry carries its own
|
|
48
|
+
// markdown, so keeping the array would keep the document a second time.
|
|
49
|
+
update_canvas: ['canvas_id', 'action', 'section_id'],
|
|
50
|
+
};
|