@haikit/mcp 0.32.0 → 0.34.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.
Files changed (52) hide show
  1. package/README.md +60 -1
  2. package/dist/auth.d.ts +151 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +334 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/credential.d.ts +55 -0
  7. package/dist/credential.d.ts.map +1 -0
  8. package/dist/credential.js +62 -0
  9. package/dist/credential.js.map +1 -0
  10. package/dist/errors.d.ts +24 -1
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +3 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +44 -11
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +511 -144
  17. package/dist/index.js.map +1 -1
  18. package/dist/oauth.d.ts +193 -0
  19. package/dist/oauth.d.ts.map +1 -0
  20. package/dist/oauth.js +558 -0
  21. package/dist/oauth.js.map +1 -0
  22. package/dist/postgres.d.ts +25 -0
  23. package/dist/postgres.d.ts.map +1 -0
  24. package/dist/postgres.js +97 -0
  25. package/dist/postgres.js.map +1 -0
  26. package/dist/routes.d.ts +17 -0
  27. package/dist/routes.d.ts.map +1 -0
  28. package/dist/routes.js +228 -0
  29. package/dist/routes.js.map +1 -0
  30. package/dist/seal.d.ts +25 -0
  31. package/dist/seal.d.ts.map +1 -0
  32. package/dist/seal.js +60 -0
  33. package/dist/seal.js.map +1 -0
  34. package/dist/store.d.ts +50 -0
  35. package/dist/store.d.ts.map +1 -0
  36. package/dist/store.js +83 -0
  37. package/dist/store.js.map +1 -0
  38. package/dist/transports.d.ts +17 -3
  39. package/dist/transports.d.ts.map +1 -1
  40. package/dist/transports.js +13 -3
  41. package/dist/transports.js.map +1 -1
  42. package/package.json +6 -2
  43. package/src/auth.ts +466 -0
  44. package/src/credential.ts +78 -0
  45. package/src/errors.ts +26 -2
  46. package/src/index.ts +555 -158
  47. package/src/oauth.ts +659 -0
  48. package/src/postgres.ts +127 -0
  49. package/src/routes.ts +243 -0
  50. package/src/seal.ts +62 -0
  51. package/src/store.ts +118 -0
  52. package/src/transports.ts +34 -4
@@ -1,15 +1,29 @@
1
- import { type StreamableHTTPClientTransportOptions, type Transport } from "@modelcontextprotocol/client";
1
+ import { type AuthProvider, type FetchLike, type StreamableHTTPClientTransportOptions, type Transport } from "@modelcontextprotocol/client";
2
2
  import { type StdioServerParameters } from "@modelcontextprotocol/client/stdio";
3
3
  export interface HttpOptions extends StreamableHTTPClientTransportOptions {
4
4
  /** Sent on every request, over any of the same name in `requestInit.headers`. */
5
5
  headers?: Record<string, string>;
6
6
  }
7
+ /** What `mcpServer({ auth })` needs to know of a transport `http()` made. */
8
+ export interface HttpMade {
9
+ /** It was given an `authProvider` of its own. */
10
+ authProvider: boolean;
11
+ /** It sends an `Authorization` header of its own. */
12
+ authorization: boolean;
13
+ /** The server's URL: the protected resource an `oauth()` server's tokens are for. */
14
+ url: URL;
15
+ /** Its `fetch`, which an `oauth()` server's requests to the authorization server go through too. */
16
+ fetch: FetchLike | undefined;
17
+ }
18
+ /** The factories `http()` made: only these can carry each user's credential. */
19
+ export declare const madeByHttp: WeakMap<object, HttpMade>;
7
20
  /**
8
21
  * A server at `url`, over Streamable HTTP. Everything but `headers` is the
9
22
  * SDK's own `StreamableHTTPClientTransportOptions`, such as an `authProvider`.
10
- * The URL is checked now, so a bad one fails at startup.
23
+ * The URL is checked now, so a bad one fails at startup. For a server with
24
+ * `auth`, each user's connection is given that user's `authProvider`.
11
25
  */
12
- export declare function http(url: string | URL, options?: HttpOptions): () => Transport;
26
+ export declare function http(url: string | URL, options?: HttpOptions): (authProvider?: AuthProvider) => Transport;
13
27
  /**
14
28
  * A server this process starts, and talks to over its stdin and stdout. Each
15
29
  * connection starts a new child; `close()` stops it. Unless `env` is given,
@@ -1 +1 @@
1
- {"version":3,"file":"transports.d.ts","sourceRoot":"","sources":["../src/transports.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiC,KAAK,oCAAoC,EAAE,KAAK,SAAS,EAAE,MAAM,8BAA8B,CAAC;AACxI,OAAO,EAAwB,KAAK,qBAAqB,EAAE,MAAM,oCAAoC,CAAC;AAEtG,MAAM,WAAW,WAAY,SAAQ,oCAAoC;IACvE,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,EAAE,OAAO,GAAE,WAAgB,GAAG,MAAM,SAAS,CASlF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,SAAS,CAEpE"}
1
+ {"version":3,"file":"transports.d.ts","sourceRoot":"","sources":["../src/transports.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,oCAAoC,EACzC,KAAK,SAAS,EACf,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAwB,KAAK,qBAAqB,EAAE,MAAM,oCAAoC,CAAC;AAEtG,MAAM,WAAW,WAAY,SAAQ,oCAAoC;IACvE,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,6EAA6E;AAC7E,MAAM,WAAW,QAAQ;IACvB,iDAAiD;IACjD,YAAY,EAAE,OAAO,CAAC;IACtB,qDAAqD;IACrD,aAAa,EAAE,OAAO,CAAC;IACvB,qFAAqF;IACrF,GAAG,EAAE,GAAG,CAAC;IACT,oGAAoG;IACpG,KAAK,EAAE,SAAS,GAAG,SAAS,CAAC;CAC9B;AAED,gFAAgF;AAChF,eAAO,MAAM,UAAU,2BAAkC,CAAC;AAE1D;;;;;GAKG;AACH,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,EAAE,OAAO,GAAE,WAAgB,GAAG,CAAC,YAAY,CAAC,EAAE,YAAY,KAAK,SAAS,CAiB7G;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,SAAS,CAEpE"}
@@ -1,9 +1,12 @@
1
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
1
+ import { StreamableHTTPClientTransport, } from "@modelcontextprotocol/client";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
3
+ /** The factories `http()` made: only these can carry each user's credential. */
4
+ export const madeByHttp = new WeakMap();
3
5
  /**
4
6
  * A server at `url`, over Streamable HTTP. Everything but `headers` is the
5
7
  * SDK's own `StreamableHTTPClientTransportOptions`, such as an `authProvider`.
6
- * The URL is checked now, so a bad one fails at startup.
8
+ * The URL is checked now, so a bad one fails at startup. For a server with
9
+ * `auth`, each user's connection is given that user's `authProvider`.
7
10
  */
8
11
  export function http(url, options = {}) {
9
12
  const target = new URL(url);
@@ -14,7 +17,14 @@ export function http(url, options = {}) {
14
17
  merged.set(name, value);
15
18
  rest.requestInit = { ...rest.requestInit, headers: merged };
16
19
  }
17
- return () => new StreamableHTTPClientTransport(target, rest);
20
+ const make = (authProvider) => new StreamableHTTPClientTransport(target, authProvider ? { ...rest, authProvider } : rest);
21
+ madeByHttp.set(make, {
22
+ authProvider: rest.authProvider !== undefined,
23
+ authorization: new Headers(rest.requestInit?.headers).has("authorization"),
24
+ url: target,
25
+ fetch: rest.fetch,
26
+ });
27
+ return make;
18
28
  }
19
29
  /**
20
30
  * A server this process starts, and talks to over its stdin and stdout. Each
@@ -1 +1 @@
1
- {"version":3,"file":"transports.js","sourceRoot":"","sources":["../src/transports.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,6BAA6B,EAA6D,MAAM,8BAA8B,CAAC;AACxI,OAAO,EAAE,oBAAoB,EAA8B,MAAM,oCAAoC,CAAC;AAOtG;;;;GAIG;AACH,MAAM,UAAU,IAAI,CAAC,GAAiB,EAAE,UAAuB,EAAE;IAC/D,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IAC5B,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;IACrC,IAAI,OAAO,EAAE,CAAC;QACZ,MAAM,MAAM,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;QACtD,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;YAAE,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC7E,IAAI,CAAC,WAAW,GAAG,EAAE,GAAG,IAAI,CAAC,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAC9D,CAAC;IACD,OAAO,GAAG,EAAE,CAAC,IAAI,6BAA6B,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;AAC/D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,KAAK,CAAC,MAA6B;IACjD,OAAO,GAAG,EAAE,CAAC,IAAI,oBAAoB,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC"}
1
+ {"version":3,"file":"transports.js","sourceRoot":"","sources":["../src/transports.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,6BAA6B,GAK9B,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,oBAAoB,EAA8B,MAAM,oCAAoC,CAAC;AAmBtG,gFAAgF;AAChF,MAAM,CAAC,MAAM,UAAU,GAAG,IAAI,OAAO,EAAoB,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,UAAU,IAAI,CAAC,GAAiB,EAAE,UAAuB,EAAE;IAC/D,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IAC5B,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;IACrC,IAAI,OAAO,EAAE,CAAC;QACZ,MAAM,MAAM,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;QACtD,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;YAAE,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC7E,IAAI,CAAC,WAAW,GAAG,EAAE,GAAG,IAAI,CAAC,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAC9D,CAAC;IACD,MAAM,IAAI,GAAG,CAAC,YAA2B,EAAE,EAAE,CAC3C,IAAI,6BAA6B,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,YAAY,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC7F,UAAU,CAAC,GAAG,CAAC,IAAI,EAAE;QACnB,YAAY,EAAE,IAAI,CAAC,YAAY,KAAK,SAAS;QAC7C,aAAa,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC;QAC1E,GAAG,EAAE,MAAM;QACX,KAAK,EAAE,IAAI,CAAC,KAAK;KAClB,CAAC,CAAC;IACH,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,KAAK,CAAC,MAA6B;IACjD,OAAO,GAAG,EAAE,CAAC,IAAI,oBAAoB,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/mcp",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Call MCP servers' tools from inside your haikit tools. The model never sees an MCP tool: your tool decides what it hears and what the browser shows. Imports only @haikit/core.",
5
5
  "keywords": [
6
6
  "haikit",
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/index.d.ts",
21
21
  "default": "./dist/index.js"
22
22
  },
23
+ "./postgres": {
24
+ "types": "./dist/postgres.d.ts",
25
+ "default": "./dist/postgres.js"
26
+ },
23
27
  "./package.json": "./package.json"
24
28
  },
25
29
  "files": [
@@ -34,7 +38,7 @@
34
38
  "prepack": "npm --prefix ../.. run build"
35
39
  },
36
40
  "dependencies": {
37
- "@haikit/core": "0.32.0",
41
+ "@haikit/core": "0.34.0",
38
42
  "@modelcontextprotocol/client": "^2.4.0",
39
43
  "@types/node": ">=22"
40
44
  },
package/src/auth.ts ADDED
@@ -0,0 +1,466 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { AuthProvider, FetchLike, OAuthClientProvider } from "@modelcontextprotocol/client";
3
+ import { LookupFailed, Lookup, NeedsAuth, type Credential } from "./credential.js";
4
+ import { OAuthServer, isSecure, type OAuthCore, type OAuthServerOptions } from "./oauth.js";
5
+ import { routes } from "./routes.js";
6
+ import { SECRET_BYTES, Sealer } from "./seal.js";
7
+ import { STORE_METHODS, type CredentialStore } from "./store.js";
8
+
9
+ export { LookupFailed, Mismatch, NeedsAuth, type Credential } from "./credential.js";
10
+ export type { OAuthServerOptions } from "./oauth.js";
11
+
12
+ export interface McpAuthOptions {
13
+ /**
14
+ * Who a conversation belongs to: the user a call made in it runs as. With
15
+ * @haikit/auth, `(id) => auth.ownerOf(id)`. `null` for no one.
16
+ */
17
+ userOf(conversationId: string): string | null | Promise<string | null>;
18
+ /**
19
+ * Where each user's OAuth credentials are kept, sealed:
20
+ * `memoryCredentialStore()`, or `pgCredentialStore(db)` from
21
+ * `@haikit/mcp/postgres`. With `secret` and `redirectUri`, for `oauth()`.
22
+ */
23
+ store?: CredentialStore;
24
+ /**
25
+ * Seals every credential at rest. Several: the first seals, and every one
26
+ * opens, so a secret can be rotated out. Each at least 32 bytes.
27
+ */
28
+ secret?: string | string[];
29
+ /**
30
+ * Where an authorization server sends the browser back, exactly as
31
+ * registered with it: its path is the callback route. https, except on
32
+ * localhost.
33
+ */
34
+ redirectUri?: string;
35
+ /**
36
+ * The signed-in user a request to the routes comes from, or `null`. With
37
+ * @haikit/auth, `async (req) => (await auth.user(req))?.id ?? null`. For
38
+ * `handler()`.
39
+ */
40
+ requestUser?(req: IncomingMessage): string | null | Promise<string | null>;
41
+ /**
42
+ * A user has connected an account, at the callback route. With the
43
+ * conversation the connect link was for, if they own it. What it throws is
44
+ * logged: the user is connected all the same.
45
+ */
46
+ onConnected?(connected: McpConnected): void | Promise<void>;
47
+ /** Where `handler()`'s routes are, and the connect links point. Default `"/mcp"`. */
48
+ basePath?: string;
49
+ /** The app's name, which an authorization server shows when the user connects. Default: the redirect URI's host. */
50
+ clientName?: string;
51
+ }
52
+
53
+ /** What `onConnected` is told. */
54
+ export interface McpConnected {
55
+ userId: string;
56
+ server: string;
57
+ /** The conversation the connect link was for: only one the user owns. */
58
+ conversationId?: string;
59
+ }
60
+
61
+ /** A server a user has connected an account for, from `connections()`. */
62
+ export interface McpAccount {
63
+ /** The server's `name`. */
64
+ name: string;
65
+ /** The scope granted, or asked for when the authorization server didn't say. */
66
+ scope: string | null;
67
+ /** When the user connected, epoch ms. */
68
+ connectedAt: number;
69
+ }
70
+
71
+ /** Makes the `auth` of servers whose calls are made as each user. */
72
+ export interface McpAuth {
73
+ /**
74
+ * Each user's own token, which the app already has: `token(userId)` gives
75
+ * it, or `null` when the user has none. Sent as `Authorization: Bearer`.
76
+ */
77
+ bearer(token: (userId: string) => string | null | Promise<string | null>): McpServerAuth;
78
+ /**
79
+ * Each user connects their account through the connect route, and the
80
+ * store keeps their tokens. Needs `store`, `secret` and `redirectUri`.
81
+ */
82
+ oauth(options?: OAuthServerOptions): McpServerAuth;
83
+ /**
84
+ * One of the SDK's own providers, made for each user: `make(userId)` gives
85
+ * it, or `null` when the user has none. The app owns the whole provider:
86
+ * nothing is stored, and there's no connect route.
87
+ */
88
+ sdk(make: (userId: string) => AuthProvider | OAuthClientProvider | null | Promise<AuthProvider | OAuthClientProvider | null>): McpServerAuth;
89
+ /**
90
+ * The routes, under `basePath`: connect, the callback at the redirect URI's
91
+ * path, disconnect, the user's connections, and the client metadata
92
+ * document. Resolves false for anything else. Needs `requestUser`.
93
+ */
94
+ handler(): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
95
+ /** The `oauth()` servers a user has connected an account for. */
96
+ connections(userId: string): Promise<McpAccount[]>;
97
+ /**
98
+ * Revokes a user's tokens for a server where its authorization server takes
99
+ * it, deletes them, and closes the user's connection; with no `server`, for
100
+ * every server they connected.
101
+ */
102
+ disconnect(userId: string, server?: string): Promise<void>;
103
+ }
104
+
105
+ /** How a server's calls are made as each user: made by an `McpAuth`, and given to `mcpServer({ auth })`. */
106
+ export interface McpServerAuth {
107
+ readonly kind: "bearer" | "oauth" | "sdk";
108
+ }
109
+
110
+ /** What a server tells its `McpAuth` of itself. */
111
+ export interface Attached {
112
+ name: string;
113
+ timeoutMs: number;
114
+ url: URL;
115
+ fetch: FetchLike | undefined;
116
+ forget(userId: string): Promise<void>;
117
+ }
118
+
119
+ /** A server's place on its `McpAuth`. */
120
+ export interface Binding {
121
+ /** The user's credential, for a new connection of theirs. */
122
+ credential(userId: string): Credential;
123
+ /** The connect route's path, for an `oauth()` server: for this conversation, and asking for `scope`. */
124
+ readonly connect: ((conversationId: string | undefined, scope: string | undefined) => string) | null;
125
+ /** Frees the name, for a closed server. */
126
+ detach(): void;
127
+ }
128
+
129
+ /** What an `McpServerAuth` stands for. */
130
+ export interface AuthConfig {
131
+ readonly kind: McpServerAuth["kind"];
132
+ userOf(conversationId: string): string | null | Promise<string | null>;
133
+ /** Takes the server's name on this `McpAuth`, or throws if another server has it: the name keys its users' credentials. */
134
+ attach(server: Attached): Binding;
135
+ }
136
+
137
+ const configs = new WeakMap<object, AuthConfig>();
138
+
139
+ /** The config an `McpServerAuth` stands for, or undefined if `createMcpAuth` didn't make it. */
140
+ export const configOf = (auth: unknown): AuthConfig | undefined =>
141
+ typeof auth === "object" && auth !== null ? configs.get(auth) : undefined;
142
+
143
+ const BASE_PATH = /^(\/[A-Za-z0-9\-._~!$&'()*+,;=:@%]+)+$/;
144
+ /** What `handler()` answers under `basePath` itself. */
145
+ const ROUTES = ["connect", "disconnect", "connections", "client.json"];
146
+
147
+ /**
148
+ * For servers whose calls are made as each user, with that user's own
149
+ * credential: `mcpServer({ auth: mcpAuth.oauth() })`.
150
+ */
151
+ export function createMcpAuth(options: McpAuthOptions): McpAuth {
152
+ if (typeof options?.userOf !== "function") {
153
+ throw new TypeError("createMcpAuth: userOf must be a function, such as (id) => auth.ownerOf(id)");
154
+ }
155
+ for (const name of ["requestUser", "onConnected"] as const) {
156
+ if (options[name] !== undefined && typeof options[name] !== "function") throw new TypeError(`createMcpAuth: ${name} must be a function`);
157
+ }
158
+ const basePath = options.basePath ?? "/mcp";
159
+ if (typeof basePath !== "string" || !BASE_PATH.test(basePath)) {
160
+ throw new TypeError(`createMcpAuth: basePath must be a path such as "/mcp", with no trailing slash (got ${JSON.stringify(basePath)})`);
161
+ }
162
+ // a request's path is read as a URL, which takes out dot segments: one there would match no route
163
+ if (new URL(basePath, "http://localhost").pathname !== basePath) {
164
+ throw new TypeError(`createMcpAuth: basePath can't have a "." or ".." segment (got ${JSON.stringify(basePath)})`);
165
+ }
166
+ const core = oauthCore(options, basePath);
167
+
168
+ /** Every server attached, by name: its kind, and its OAuth if it has one. */
169
+ const servers = new Map<string, { forget(userId: string): Promise<void>; oauth: OAuthServer | null }>();
170
+ const userOf = (conversationId: string) => options.userOf(conversationId);
171
+
172
+ function make(kind: McpServerAuth["kind"], bind: (server: Attached) => Omit<Binding, "detach">): McpServerAuth {
173
+ const auth: McpServerAuth = Object.freeze({ kind });
174
+ configs.set(auth, {
175
+ kind,
176
+ userOf,
177
+ attach(server) {
178
+ if (servers.has(server.name)) throw new TypeError(`another server from the same createMcpAuth is named "${server.name}"`);
179
+ const bound = bind(server);
180
+ const entry = { forget: server.forget, oauth: bound instanceof OAuthBinding ? bound.oauth : null };
181
+ servers.set(server.name, entry);
182
+ return {
183
+ credential: (user) => bound.credential(user),
184
+ connect: bound.connect,
185
+ detach: () => {
186
+ if (servers.get(server.name) === entry) servers.delete(server.name);
187
+ },
188
+ };
189
+ },
190
+ });
191
+ return auth;
192
+ }
193
+
194
+ const needsStore = (what: string) => {
195
+ if (!core) throw new TypeError(`${what} needs createMcpAuth({ store, secret, redirectUri })`);
196
+ return core;
197
+ };
198
+
199
+ return Object.freeze({
200
+ bearer(token: (userId: string) => string | null | Promise<string | null>): McpServerAuth {
201
+ if (typeof token !== "function") {
202
+ throw new TypeError("bearer: token must be a function that takes a user id and returns their token, or null");
203
+ }
204
+ return make("bearer", (server) => ({
205
+ credential: (user: string) => new BearerCredential(token, user, server.timeoutMs),
206
+ connect: null,
207
+ }));
208
+ },
209
+
210
+ oauth(oauthOptions: OAuthServerOptions = {}): McpServerAuth {
211
+ const ready = needsStore("oauth()");
212
+ const checked = checkOAuth(oauthOptions);
213
+ return make("oauth", (server) => new OAuthBinding(new OAuthServer(ready, checked, server.name, server.url, server.fetch ?? fetch, server.timeoutMs, server.forget), basePath));
214
+ },
215
+
216
+ sdk(makeProvider: (userId: string) => ProviderOrNull | Promise<ProviderOrNull>): McpServerAuth {
217
+ if (typeof makeProvider !== "function") {
218
+ throw new TypeError("sdk: make must be a function that takes a user id and returns their AuthProvider, or null");
219
+ }
220
+ return make("sdk", (server) => ({
221
+ credential: (user: string) => new SdkCredential(makeProvider, user, server.timeoutMs),
222
+ connect: null,
223
+ }));
224
+ },
225
+
226
+ handler() {
227
+ const ready = needsStore("handler()");
228
+ if (!options.requestUser) throw new TypeError("handler() needs createMcpAuth's requestUser: its routes act for the user signed in");
229
+ const requestUser = options.requestUser;
230
+ return routes({
231
+ core: ready,
232
+ basePath,
233
+ requestUser: async (req) => nonEmpty(await requestUser(req)),
234
+ userOf: async (id) => nonEmpty(await options.userOf(id)),
235
+ onConnected: options.onConnected,
236
+ oauth: (name) => servers.get(name)?.oauth ?? undefined,
237
+ connections,
238
+ disconnect,
239
+ });
240
+ },
241
+
242
+ connections,
243
+ disconnect,
244
+ });
245
+
246
+ async function connections(userId: string): Promise<McpAccount[]> {
247
+ const ready = needsStore("connections()");
248
+ if (typeof userId !== "string" || userId === "") throw new TypeError("connections() needs a userId");
249
+ const accounts: McpAccount[] = [];
250
+ for (const name of await ready.store.list(userId)) {
251
+ const held = await servers.get(name)?.oauth?.read(userId);
252
+ if (held) accounts.push({ name, scope: held.kept.scope, connectedAt: held.kept.connectedAt });
253
+ }
254
+ return accounts;
255
+ }
256
+
257
+ async function disconnect(userId: string, server?: string): Promise<void> {
258
+ const ready = needsStore("disconnect()");
259
+ if (typeof userId !== "string" || userId === "") throw new TypeError("disconnect() needs a userId");
260
+ if (server !== undefined && (typeof server !== "string" || server === "")) throw new TypeError("disconnect(): server must be a server's name");
261
+ for (const name of server === undefined ? await ready.store.list(userId) : [server]) {
262
+ const oauth = servers.get(name)?.oauth;
263
+ if (oauth) await oauth.disconnect(userId);
264
+ // a server no longer configured: nothing to revoke with, or close
265
+ else await ready.store.delete(userId, name);
266
+ }
267
+ }
268
+ }
269
+
270
+ /** An `oauth()` server's place on its `McpAuth`. */
271
+ class OAuthBinding {
272
+ constructor(
273
+ readonly oauth: OAuthServer,
274
+ private readonly basePath: string,
275
+ ) {}
276
+
277
+ credential(user: string): Credential {
278
+ return this.oauth.credential(user);
279
+ }
280
+
281
+ readonly connect = (conversationId: string | undefined, scope: string | undefined): string => {
282
+ const query = new URLSearchParams();
283
+ if (conversationId !== undefined) query.set("conversation", conversationId);
284
+ if (scope !== undefined) query.set("scope", scope);
285
+ const search = query.toString();
286
+ return `${this.basePath}/connect/${encodeURIComponent(this.oauth.name)}${search ? `?${search}` : ""}`;
287
+ };
288
+ }
289
+
290
+ /** What `oauth()` needs of `createMcpAuth`'s options, checked: null if none of it is given. */
291
+ function oauthCore(options: McpAuthOptions, basePath: string): OAuthCore | null {
292
+ const { store, secret, redirectUri } = options;
293
+ if (store === undefined && secret === undefined && redirectUri === undefined) return null;
294
+ if (store === undefined || secret === undefined || redirectUri === undefined) {
295
+ throw new TypeError("createMcpAuth: store, secret and redirectUri go together: oauth() needs all three");
296
+ }
297
+ if (typeof store !== "object" || store === null || STORE_METHODS.some((m) => typeof store[m] !== "function")) {
298
+ throw new TypeError("createMcpAuth: store must be a CredentialStore, such as memoryCredentialStore()");
299
+ }
300
+ const secrets = [secret].flat();
301
+ if (!secrets.length || secrets.some((s) => typeof s !== "string")) throw new TypeError("createMcpAuth: secret must be a string, or a list of them");
302
+ if (secrets.some((s) => Buffer.byteLength(s) < SECRET_BYTES)) throw new TypeError(`createMcpAuth: each secret must be at least ${SECRET_BYTES} bytes`);
303
+ let callback: URL;
304
+ try {
305
+ callback = new URL(redirectUri);
306
+ } catch {
307
+ throw new TypeError("createMcpAuth: redirectUri must be a URL, such as https://app.example.com/mcp/callback");
308
+ }
309
+ if (!isSecure(callback)) throw new TypeError("createMcpAuth: redirectUri must be https, except on localhost");
310
+ if (callback.hash || callback.search) throw new TypeError("createMcpAuth: redirectUri can't have a query or a fragment");
311
+ const path = callback.pathname;
312
+ if (ROUTES.some((route) => path === `${basePath}/${route}` || path.startsWith(`${basePath}/${route}/`))) {
313
+ throw new TypeError(`createMcpAuth: redirectUri comes back to ${path}, a route handler() answers already`);
314
+ }
315
+ const clientName = options.clientName ?? callback.host;
316
+ if (typeof clientName !== "string" || clientName === "") throw new TypeError("createMcpAuth: clientName must be a non-empty string");
317
+ return {
318
+ store,
319
+ sealer: new Sealer(secrets),
320
+ redirectUri: callback.href,
321
+ clientMetadata: {
322
+ client_name: clientName,
323
+ redirect_uris: [callback.href],
324
+ grant_types: ["authorization_code", "refresh_token"],
325
+ response_types: ["code"],
326
+ token_endpoint_auth_method: "none",
327
+ },
328
+ // the SDK refuses a client identified by a URL that isn't https: on localhost, it registers one
329
+ clientMetadataUrl: callback.protocol === "https:" ? new URL(`${basePath}/client.json`, callback).href : undefined,
330
+ };
331
+ }
332
+
333
+ function checkOAuth(options: OAuthServerOptions): OAuthServerOptions {
334
+ if (typeof options !== "object" || options === null) throw new TypeError("oauth: options must be an object");
335
+ const { issuer, scopes, client } = options;
336
+ if (issuer !== undefined) {
337
+ if (typeof issuer !== "string" || !URL.canParse(issuer)) throw new TypeError("oauth: issuer must be the authorization server's issuer URL");
338
+ if (!isSecure(issuer)) throw new TypeError("oauth: issuer must be https, except on localhost");
339
+ }
340
+ if (scopes !== undefined && (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string" || !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(s)))) {
341
+ throw new TypeError("oauth: scopes must be a list of OAuth scopes, such as [\"repo\"], each without spaces");
342
+ }
343
+ if (client !== undefined) {
344
+ if (typeof client?.id !== "string" || client.id === "" || (client.secret !== undefined && typeof client.secret !== "string")) {
345
+ throw new TypeError("oauth: client must be { id, secret? }, as registered with the authorization server");
346
+ }
347
+ if (issuer === undefined) throw new TypeError("oauth: a client needs the issuer it was registered with");
348
+ }
349
+ return Object.freeze({ ...options, ...(scopes ? { scopes: [...scopes] } : {}), ...(client ? { client: { ...client } } : {}) });
350
+ }
351
+
352
+ type ProviderOrNull = AuthProvider | OAuthClientProvider | null;
353
+
354
+ const nonEmpty = (value: unknown): string | null => (typeof value === "string" && value !== "" ? value : null);
355
+
356
+ /** A token from `bearer`'s function, kept until a lookup on a 401 finds another, or none. */
357
+ export class BearerCredential implements Credential {
358
+ private token: string | null = null;
359
+ /** The lookup on its way, which every caller shares: only it sets `token`. */
360
+ private looking: Promise<string | null> | null = null;
361
+ /** Bounds `read`: a lookup that never answers would hold up every call after it. */
362
+ private readonly lookups: Lookup;
363
+
364
+ constructor(
365
+ private readonly read: (userId: string) => string | null | Promise<string | null>,
366
+ private readonly user: string,
367
+ /** How long `read` may take. */
368
+ timeoutMs: number,
369
+ ) {
370
+ this.lookups = new Lookup(timeoutMs, "token");
371
+ }
372
+
373
+ async ensure(): Promise<boolean> {
374
+ if (this.token === null) await this.look();
375
+ if (this.token === null) throw new NeedsAuth("connect");
376
+ return false;
377
+ }
378
+
379
+ close(): void {
380
+ this.lookups.stop();
381
+ }
382
+
383
+ // The transport asks for the token before every request, and on a 401
384
+ // calls onUnauthorized, then tries once more with what it finds.
385
+ readonly provider: AuthProvider = {
386
+ // A request is never sent without the user's token: the transport would
387
+ // send it with no Authorization at all. Gone since its call began, as
388
+ // when another call's 401 found none, it stops the request here.
389
+ token: async () => {
390
+ if (this.token === null) throw new NeedsAuth("reconnect");
391
+ return this.token;
392
+ },
393
+ onUnauthorized: async () => {
394
+ // A token the server still refuses gets its 401 on the retry, and is
395
+ // kept: the next 401 looks again. Forgetting it would throw away a
396
+ // newer one that another call's 401 found meanwhile.
397
+ if ((await this.look()) === null) throw new NeedsAuth("reconnect");
398
+ },
399
+ };
400
+
401
+ /** One lookup at a time, for first calls and 401s that come together, so none overwrites another's token. */
402
+ private look(): Promise<string | null> {
403
+ this.looking ??= this.lookup()
404
+ .then((token) => (this.token = token))
405
+ .finally(() => {
406
+ this.looking = null;
407
+ });
408
+ return this.looking;
409
+ }
410
+
411
+ private async lookup(): Promise<string | null> {
412
+ let token: unknown;
413
+ try {
414
+ token = await this.lookups.run(() => this.read(this.user));
415
+ } catch (err) {
416
+ throw new LookupFailed(err);
417
+ }
418
+ return typeof token === "string" && token !== "" ? token : null;
419
+ }
420
+ }
421
+
422
+ /** A provider from `sdk()`'s function, made once for each connection: the app's provider does the rest. */
423
+ class SdkCredential implements Credential {
424
+ private made: AuthProvider | OAuthClientProvider | null = null;
425
+ private looking: Promise<void> | null = null;
426
+ private readonly lookups: Lookup;
427
+
428
+ constructor(
429
+ private readonly make: (userId: string) => ProviderOrNull | Promise<ProviderOrNull>,
430
+ private readonly user: string,
431
+ timeoutMs: number,
432
+ ) {
433
+ this.lookups = new Lookup(timeoutMs, "provider");
434
+ }
435
+
436
+ async ensure(): Promise<boolean> {
437
+ if (this.made === null) {
438
+ this.looking ??= this.lookups
439
+ .run(() => this.make(this.user))
440
+ .then(
441
+ (made) => {
442
+ this.made = typeof made === "object" && made !== null ? made : null;
443
+ },
444
+ (err) => {
445
+ throw new LookupFailed(err);
446
+ },
447
+ )
448
+ .finally(() => {
449
+ this.looking = null;
450
+ });
451
+ await this.looking;
452
+ }
453
+ if (this.made === null) throw new NeedsAuth("connect");
454
+ return false;
455
+ }
456
+
457
+ close(): void {
458
+ this.lookups.stop();
459
+ }
460
+
461
+ get provider(): AuthProvider | OAuthClientProvider {
462
+ // never asked for before ensure() has made it: a connection dials only after
463
+ if (this.made === null) throw new NeedsAuth("connect");
464
+ return this.made;
465
+ }
466
+ }
@@ -0,0 +1,78 @@
1
+ import type { AuthProvider, OAuthClientProvider } from "@modelcontextprotocol/client";
2
+
3
+ /**
4
+ * The user has no credential, or the server refused theirs: thrown inside a
5
+ * connection, where the transport passes it on unchanged, and made an
6
+ * `McpError` with reason `auth`.
7
+ */
8
+ export class NeedsAuth extends Error {
9
+ readonly name = "NeedsAuth";
10
+ constructor(readonly kind: "connect" | "reconnect") {
11
+ super(kind === "connect" ? "the user has no credential" : "the server refused the user's credential");
12
+ }
13
+ }
14
+
15
+ /** The app's lookup of a credential failed, or a refresh couldn't be made: made an `McpError` with reason `connection`. */
16
+ export class LookupFailed extends Error {
17
+ readonly name = "LookupFailed";
18
+ constructor(cause: unknown) {
19
+ super("the user's credential could not be looked up", { cause });
20
+ }
21
+ }
22
+
23
+ /**
24
+ * The server names another authorization server than the one its `issuer`
25
+ * pins, or than the one that issued a credential: made an `McpError` with
26
+ * reason `protocol`. The pin decides, not the user, so it's never `auth`.
27
+ */
28
+ export class Mismatch extends Error {
29
+ readonly name = "Mismatch";
30
+ }
31
+
32
+ /** One user's credential for one server, and the `AuthProvider` that sends it. */
33
+ export interface Credential {
34
+ /**
35
+ * Before a connection or call: finds the credential, or throws
36
+ * `NeedsAuth("connect")`, so nothing is sent without one. True when it's
37
+ * now another account than before: the connection made with the old one
38
+ * is made again.
39
+ */
40
+ ensure(): Promise<boolean>;
41
+ /** For a connection being closed: stops a lookup on its way, and its timer. */
42
+ close(): void;
43
+ /** What each connection's transport is given, read as it's made, once `ensure()` has found the credential. */
44
+ readonly provider: AuthProvider | OAuthClientProvider;
45
+ }
46
+
47
+ /**
48
+ * A lookup that takes at most `ms`, and that `stop()` ends at once: one that
49
+ * never answered would hold up every call after it, and its timer would keep
50
+ * the process going.
51
+ */
52
+ export class Lookup {
53
+ private stopping: (() => void) | null = null;
54
+
55
+ constructor(
56
+ private readonly ms: number,
57
+ /** Names what's looked up, in the errors. */
58
+ private readonly what: string,
59
+ ) {}
60
+
61
+ async run<T>(look: () => T | Promise<T>): Promise<T> {
62
+ let timer: ReturnType<typeof setTimeout> | undefined;
63
+ const cut = new Promise<never>((_, reject) => {
64
+ timer = setTimeout(() => reject(new Error(`no ${this.what} within ${this.ms} ms`)), this.ms);
65
+ this.stopping = () => reject(new Error(`closed while the ${this.what} was being looked up`));
66
+ });
67
+ try {
68
+ return await Promise.race([Promise.resolve().then(look), cut]);
69
+ } finally {
70
+ clearTimeout(timer);
71
+ this.stopping = null;
72
+ }
73
+ }
74
+
75
+ stop(): void {
76
+ this.stopping?.();
77
+ }
78
+ }