@lanes-sh/link 0.1.1 → 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 CHANGED
@@ -1,15 +1,19 @@
1
1
  # Lanes Link
2
2
 
3
- **One secure endpoint between your AI agents and your real accounts, knowledge, and secrets.**
3
+ [![npm version](https://img.shields.io/npm/v/%40lanes-sh%2Flink?style=flat-square&color=black&label=npm)](https://www.npmjs.com/package/@lanes-sh/link)
4
+ [![license Apache-2.0](https://img.shields.io/github/license/lanes-sh/link?style=flat-square&color=black)](LICENSE)
5
+ [![ci](https://img.shields.io/github/actions/workflow/status/lanes-sh/link/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/lanes-sh/link/actions/workflows/ci.yml)
6
+
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
6
10
  have. Every agent you use — Claude, ChatGPT, and anything else that speaks MCP — reaches them
7
- through a single MCP endpoint that you own and run. Open source, self-hosted, no vendor sitting in
11
+ through a single MCP endpoint that you own and run. Open source, self-hostable, no vendor sitting in
8
12
  the middle of your data.
9
13
 
10
14
  <picture>
11
15
  <source media="(prefers-color-scheme: dark)" srcset="docs/images/lanes-link-dark.svg">
12
- <img alt="Claude, ChatGPT, and any MCP client reach one Lanes Link endpoint, which you run on your own machine or your own cloud. The profiles it serves sit inside that boundary: Work, holding Gmail, Calendar, and Docs; and Personal, holding Gmail, Memory, and Skills." src="docs/images/lanes-link-light.svg">
16
+ <img alt="Claude, Codex, and Gemini all reach one Lanes Link endpoint, which you run yourself. Beneath it sit the profiles it serves: personal, holding Gmail, Memory, and Skills; and work, holding Gmail, Calendar, and Docs." src="docs/images/lanes-link-light.svg">
13
17
  </picture>
14
18
 
15
19
  ## Why
@@ -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
- Two things worth knowing up front: `lanes link connect icloud` sets up Mail, Calendar, and Contacts
100
- together, because one app-specific password covers all three. And Google needs no OAuth client of
101
- your own: `lanes link connect gmail` authorises against the one Lanes operates, so there is no
102
- Cloud console to visit. Add `--own-client` if you would rather register your own.
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)**.
@@ -5,7 +5,7 @@ description: Use when the user refers to their own accounts, knowledge, procedur
5
5
 
6
6
  # Lanes Link
7
7
 
8
- A self-hosted gateway to one person's own context: the accounts they have
8
+ A self-hostable gateway to one person's own context: the accounts they have
9
9
  connected, the knowledge they have accumulated, the procedures they have
10
10
  written down, and their secrets. One endpoint serves every profile in a
11
11
  workspace under one token, and each call names which profile it means.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.1.1",
4
- "description": "A self-hosted MCP gateway for your accounts, memory, skills, and secrets",
3
+ "version": "0.2.0",
4
+ "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
- "homepage": "https://github.com/lanes-sh/link#readme",
6
+ "homepage": "https://lanes.sh/link",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/lanes-sh/link.git"
@@ -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
- export function oauthProviderFor(
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, oauthProviderFor } from './authorise.ts';
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 `resolveUpstreamToken` wraps around it. Every other kind carries
270
- // whatever credential it needs from the factory.
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
- accessToken: async () => {
276
- const provider = oauthProviderFor(manifest, connectionId, runtime.credentials);
277
- const tokens = (await provider.tokens()) as { access_token?: string } | undefined;
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: async () => {
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: (manifest.connector as { endpoint: string }).endpoint,
67
- accessToken: async () => {
68
- const provider = oauthProviderFor(manifest, provisionalId, runtime.credentials);
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,
package/src/cli/lanes.ts CHANGED
@@ -18,7 +18,7 @@ import { version } from './version.ts';
18
18
  */
19
19
 
20
20
  const AREAS: Record<string, string> = {
21
- link: 'a self-hosted MCP gateway for your accounts, memory, skills, and secrets',
21
+ link: 'a self-hostable MCP gateway for all your connections, memory, skills, and secrets',
22
22
  };
23
23
 
24
24
  function areasUsage(): string {
package/src/cli/usage.ts CHANGED
@@ -16,7 +16,7 @@ import { style } from './output.ts';
16
16
  /** How this CLI is invoked — the `link` area of the `lanes` command. */
17
17
  export const PROGRAM = 'lanes link';
18
18
 
19
- export const USAGE = `${style.bold(PROGRAM)} — a self-hosted MCP gateway for your accounts, memory, skills, and secrets
19
+ export const USAGE = `${style.bold(PROGRAM)} — a self-hostable MCP gateway for all your connections, memory, skills, and secrets
20
20
 
21
21
  ${style.bold('Everyday')}
22
22
  ${PROGRAM} setup plan [--json] what each provider needs, and which are connected
@@ -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 two dispatchers here are the only files
7
- * that know the whole set.
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 { resolveUpstreamToken } from '#connectivity/auth/oauth-authcode/index.ts';
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
- accessToken: () => resolveUpstreamToken(manifest, connectionId, options.credentials),
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: token ? { authorization: `Bearer ${token}` } : {},
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
+ };
@@ -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
+ };
@@ -38,7 +38,7 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
38
38
  // rather than trusting a copy to stay one. It is the whole endpoint being
39
39
  // described, not this connection, so it does not name the profiles the
40
40
  // way `title` does.
41
- description: 'A self-hosted MCP gateway for your accounts, memory, skills, and secrets',
41
+ description: 'A self-hostable MCP gateway for all your connections, memory, skills, and secrets',
42
42
  websiteUrl: 'https://github.com/lanes-sh/link',
43
43
  icons: SERVER_ICONS,
44
44
  },