@byokit/connect 0.5.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.1 (2026-10-17)
6
+
7
+ - Dependency update: pins @byokit/secrets 0.5.2.
8
+
5
9
  ## 0.5.0 (2026-10-17)
6
10
 
7
11
  - FEAT: `deviceSignIn(provider, options)` connects a person with an RFC 8628 device code: it returns `{ userCode, verificationUri, expiresAt, finish(), cancel() }`, polls with the app's own OAuth client ID (pending waits, `slow_down` adds 5 s), and stores the grant in the person's keystore only when they approve. An expired code rejects with `ConnectError` `expired`; a person cancelling on the provider's page, or `cancel()`, rejects with `declined`, and neither stores anything. New `github` provider (`device` endpoints on github.com) and `Provider.device`; `ConnectOptions.redirectUri` is now optional (`signIn()` still requires it). RFC 8628 error codes stay readable in `ConnectError.cause`. The `expired` message now reads "This sign-in has expired. Start again."
package/README.md CHANGED
@@ -218,6 +218,44 @@ A 401 triggers one refresh/retry; redirects are refused. Close clients before
218
218
  `disconnect()` to release their streams. Arbitrary unauthenticated MCP servers and
219
219
  legacy HTTP+SSE transports are outside this sign-in kit's initial scope.
220
220
 
221
+ ## List apps to connect
222
+
223
+ ```ts
224
+ import { catalog, connect, knownApps, mcpDirectory } from '@byokit/connect';
225
+ import type { Keystore } from '@byokit/secrets';
226
+
227
+ declare const store: Keystore;
228
+ const apps = await catalog([knownApps, mcpDirectory('https://directory.your-app.example/v0/servers?version=latest')]);
229
+ const app = apps[0]; // { id, name, icon?, auth, ...the Provider connect() takes }
230
+ const connection = connect(app, { store, person: 'Umer', redirectUri: 'https://your-app.example/connect/callback' });
231
+ const flow = await connection.signIn(); // Or connectLoopback(app, ...); deviceSignIn(app, ...) when app.auth is 'device'.
232
+ ```
233
+
234
+ `catalog(sources)` lists the apps of the sources the host picks, in that order; an
235
+ id listed twice keeps its first entry. Nothing is listed, and nothing is read, by
236
+ default. Every listed app is a `Provider`, so it signs in through the same
237
+ `connect()`, `connectLoopback()` and `deviceSignIn()` as above, under the same
238
+ keystore record as the preset of that id. `auth` says how: `oauth` needs the app's
239
+ registered `client`, `discovery` finds sign-in from the app's MCP server and
240
+ registers itself where offered, `device` shows a typed code. `icon` is an HTTPS
241
+ image address from the listing; render it as untrusted content.
242
+
243
+ - `knownApps` lists the kit's presets (`providers`) and reads nothing.
244
+ - `mcpDirectory(url, { fetch?, timeoutMs? })` reads a remote MCP directory that
245
+ answers in the MCP registry's `GET /v0/servers` shape, following
246
+ `metadata.nextCursor` (at most 100 pages), only when listed. It keeps servers
247
+ with a streamable HTTP remote and no address variables or required headers;
248
+ local packages, legacy SSE remotes and key-header servers are left out. The URL
249
+ must be HTTPS (or loopback); for the MCP registry add `version=latest`.
250
+ - Any object with `list(): Promise<CatalogApp[]>` is a source, such as the host's
251
+ own managed connector directory behind its server.
252
+
253
+ A directory that fails or answers in another shape rejects with `ConnectError`
254
+ code `catalog` (with its `status`), an unreachable one with `network`; the whole
255
+ `catalog()` call rejects with it, so list a source on its own to keep the rest.
256
+ The host vouches for the directories it names: signing in to a listed app talks
257
+ to the server that listing names.
258
+
221
259
  ## Read and search Gmail
222
260
 
223
261
  ```ts
@@ -342,7 +380,8 @@ the connection handle; never persist its returned access token separately.
342
380
  ## Crewhouse adoption
343
381
 
344
382
  Replace the OAuth, token-file and `RemoteMcp` portions of `src/connections.ts` with
345
- one handle per member/app. Pass the member's keystore and member ID, the house Google
383
+ one handle per member/app, listing the apps from `catalog([knownApps])` (add `mcpDirectory(url)`
384
+ or the host's own source to list more). Pass the member's keystore and member ID, the house Google
346
385
  client from `googleClientFile` when applicable, and Crewhouse's callback address. Keep
347
386
  flow handles in the host's callback routing table until completion/cancellation.
348
387
  Crewhouse retains its own connection screen, Google setup guidance and tool gates.
@@ -359,7 +398,8 @@ sh scripts/test.sh 'packages/connect/test/*.test.ts'
359
398
  ```
360
399
 
361
400
  `packages/connect/test/journey-connect.test.ts` drives the built package the way a
362
- host app does — `connect`/`signIn`/`deviceSignIn`/`token`/`mcp`, the loopback listener, the house
401
+ host app does — `connect`/`signIn`/`deviceSignIn`/`token`/`mcp`, `catalog` over the known apps and a
402
+ loopback MCP directory (one listed app connected end to end), the loopback listener, the house
363
403
  Google client, `MailReader`/`MailSender` and `MailHistory`. Fake providers and a real
364
404
  loopback callback only. It covers PKCE/state and exact-callback binding, per-person
365
405
  isolation, refresh races and revocation, discovery/DCR/resource binding, rich MCP
@@ -0,0 +1,26 @@
1
+ import type { Provider } from './types.ts';
2
+ /** How a listed app signs in: `oauth` with the app's own registered client, `discovery` from its MCP server
3
+ * (registering itself where the server offers it), `device` with a typed code and the app's own client. */
4
+ export type CatalogAuth = 'oauth' | 'discovery' | 'device';
5
+ /** A listed app is a `Provider`: pass it to `connect()`, `connectLoopback()` or, for `device`, `deviceSignIn()`. */
6
+ export interface CatalogApp extends Provider {
7
+ /** An HTTPS (or loopback) image address from the listing; render it as untrusted content. */
8
+ icon?: string;
9
+ auth: CatalogAuth;
10
+ }
11
+ /** Any source of apps a host can list; the kit ships `knownApps` and `mcpDirectory(url)`. */
12
+ export interface CatalogProvider {
13
+ list(): Promise<readonly CatalogApp[]>;
14
+ }
15
+ export interface DirectoryOptions {
16
+ fetch?: typeof fetch;
17
+ timeoutMs?: number;
18
+ }
19
+ /** The kit's hand-known apps (`providers`); reads nothing. */
20
+ export declare const knownApps: CatalogProvider;
21
+ /** A remote MCP directory at the caller's HTTPS (or loopback) URL, answering in the MCP registry's `GET /v0/servers`
22
+ * shape and paged by `metadata.nextCursor`. Nothing is read until `list()`. The caller vouches for the directory:
23
+ * signing in to a listed app talks to the server it names. */
24
+ export declare function mcpDirectory(url: string, options?: DirectoryOptions): CatalogProvider;
25
+ /** Lists the apps of the providers the caller picks, in their order; an id listed twice keeps its first entry. */
26
+ export declare function catalog(sources: readonly CatalogProvider[]): Promise<CatalogApp[]>;
@@ -0,0 +1,76 @@
1
+ import { ConnectError } from './errors.js';
2
+ import { endpoint, json, request } from './http.js';
3
+ import { providers } from './providers.js';
4
+ function listed(provider, icon) {
5
+ return { ...provider, ...(icon ? { icon } : {}), auth: provider.device ? 'device' : provider.oauth ? 'oauth' : 'discovery' };
6
+ }
7
+ function web(raw) {
8
+ if (typeof raw !== 'string')
9
+ return undefined;
10
+ try {
11
+ return endpoint(raw).href;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /** One MCP registry `server.json` entry, kept only when the kit can sign in to it: a streamable HTTP remote with no
18
+ * address variables or required headers for the person to fill in. */
19
+ function fromServer(server) {
20
+ if (!server || typeof server !== 'object')
21
+ return undefined;
22
+ const { name, title, remotes, icons } = server;
23
+ if (typeof name !== 'string' || !name)
24
+ return undefined;
25
+ const remote = Array.isArray(remotes) ? remotes.find(r => r?.type === 'streamable-http' && typeof r.url === 'string' && !r.url.includes('{') &&
26
+ !(Array.isArray(r.headers) && r.headers.some((h) => h?.isRequired))) : undefined;
27
+ const mcpUrl = web(remote?.url);
28
+ if (!mcpUrl)
29
+ return undefined;
30
+ const icon = Array.isArray(icons) ? icons.map(i => web(i?.src)).find(Boolean) : undefined;
31
+ return listed({ id: name, name: typeof title === 'string' && title ? title : name, mcpUrl }, icon);
32
+ }
33
+ /** The kit's hand-known apps (`providers`); reads nothing. */
34
+ export const knownApps = { list: async () => Object.values(providers).map(provider => listed(provider)) };
35
+ /** A remote MCP directory at the caller's HTTPS (or loopback) URL, answering in the MCP registry's `GET /v0/servers`
36
+ * shape and paged by `metadata.nextCursor`. Nothing is read until `list()`. The caller vouches for the directory:
37
+ * signing in to a listed app talks to the server it names. */
38
+ export function mcpDirectory(url, options = {}) {
39
+ const base = endpoint(url);
40
+ return { list: async () => {
41
+ const apps = [];
42
+ let cursor;
43
+ // ponytail: at most 100 pages per list(), so a directory that keeps paging stays finite; raise it if a real directory outgrows it.
44
+ for (let page = 0; page < 100; page++) {
45
+ const next = new URL(base);
46
+ if (cursor)
47
+ next.searchParams.set('cursor', cursor);
48
+ const response = await request(options.fetch ?? globalThis.fetch, next, { headers: { accept: 'application/json' } }, options.timeoutMs ?? 20_000);
49
+ if (!response.ok) {
50
+ await response.body?.cancel();
51
+ throw new ConnectError('catalog', response.status);
52
+ }
53
+ const body = await json(response, 'catalog');
54
+ if (!Array.isArray(body.servers))
55
+ throw new ConnectError('catalog', response.status);
56
+ for (const entry of body.servers) {
57
+ const app = fromServer(entry?.server);
58
+ if (app)
59
+ apps.push(app);
60
+ }
61
+ const more = body.metadata?.nextCursor;
62
+ if (typeof more !== 'string' || !more || more === cursor)
63
+ break;
64
+ cursor = more;
65
+ }
66
+ return apps;
67
+ } };
68
+ }
69
+ /** Lists the apps of the providers the caller picks, in their order; an id listed twice keeps its first entry. */
70
+ export async function catalog(sources) {
71
+ const apps = new Map();
72
+ for (const app of (await Promise.all(sources.map(source => source.list()))).flat())
73
+ if (!apps.has(app.id))
74
+ apps.set(app.id, app);
75
+ return [...apps.values()];
76
+ }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ConnectErrorCode = 'configuration' | 'discovery' | 'registration' | 'callback' | 'expired' | 'declined' | 'scope' | 'token' | 'signin' | 'network';
1
+ export type ConnectErrorCode = 'configuration' | 'discovery' | 'registration' | 'callback' | 'expired' | 'declined' | 'scope' | 'token' | 'signin' | 'network' | 'catalog';
2
2
  export interface ProviderErrorCause {
3
3
  readonly error: string;
4
4
  readonly error_description?: string;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { connect, deviceSignIn, Connection } from './connect.ts';
2
2
  export { providers } from './providers.ts';
3
3
  export type { ProviderId } from './providers.ts';
4
+ export { catalog, knownApps, mcpDirectory } from './catalog.ts';
5
+ export type { CatalogApp, CatalogAuth, CatalogProvider, DirectoryOptions } from './catalog.ts';
4
6
  export { ConnectError } from './errors.ts';
5
7
  export { MailReader, MailSender, MailError } from './mail.ts';
6
8
  export { MailHistory, HistoryError } from './history.ts';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { connect, deviceSignIn, Connection } from './connect.js';
2
2
  export { providers } from './providers.js';
3
+ export { catalog, knownApps, mcpDirectory } from './catalog.js';
3
4
  export { ConnectError } from './errors.js';
4
5
  export { MailReader, MailSender, MailError } from './mail.js';
5
6
  export { MailHistory, HistoryError } from './history.js';
package/dist/words.json CHANGED
@@ -9,7 +9,8 @@
9
9
  "scope": "Some permissions were left out. Sign in again and select them.",
10
10
  "token": "The app did not finish connecting. Try again.",
11
11
  "signin": "Sign in to this app again.",
12
- "network": "Could not reach the app. Check your connection and try again."
12
+ "network": "Could not reach the app. Check your connection and try again.",
13
+ "catalog": "Could not load the list of apps. Try again later."
13
14
  },
14
15
  "browser": {
15
16
  "unavailable": "This page is not available.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/connect",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Connect a person's third-party apps with PKCE, refresh and typed remote MCP.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -37,6 +37,6 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "1.31.0",
40
- "@byokit/secrets": "0.5.1"
40
+ "@byokit/secrets": "0.5.2"
41
41
  }
42
42
  }