@haikit/mcp 0.33.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 (51) hide show
  1. package/README.md +46 -7
  2. package/dist/auth.d.ts +107 -36
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +252 -37
  5. package/dist/auth.js.map +1 -1
  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 +7 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js.map +1 -1
  13. package/dist/index.d.ts +6 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +66 -9
  16. package/dist/index.js.map +1 -1
  17. package/dist/oauth.d.ts +193 -0
  18. package/dist/oauth.d.ts.map +1 -0
  19. package/dist/oauth.js +558 -0
  20. package/dist/oauth.js.map +1 -0
  21. package/dist/postgres.d.ts +25 -0
  22. package/dist/postgres.d.ts.map +1 -0
  23. package/dist/postgres.js +97 -0
  24. package/dist/postgres.js.map +1 -0
  25. package/dist/routes.d.ts +17 -0
  26. package/dist/routes.d.ts.map +1 -0
  27. package/dist/routes.js +228 -0
  28. package/dist/routes.js.map +1 -0
  29. package/dist/seal.d.ts +25 -0
  30. package/dist/seal.d.ts.map +1 -0
  31. package/dist/seal.js +60 -0
  32. package/dist/seal.js.map +1 -0
  33. package/dist/store.d.ts +50 -0
  34. package/dist/store.d.ts.map +1 -0
  35. package/dist/store.js +83 -0
  36. package/dist/store.js.map +1 -0
  37. package/dist/transports.d.ts +5 -1
  38. package/dist/transports.d.ts.map +1 -1
  39. package/dist/transports.js +2 -0
  40. package/dist/transports.js.map +1 -1
  41. package/package.json +6 -2
  42. package/src/auth.ts +354 -48
  43. package/src/credential.ts +78 -0
  44. package/src/errors.ts +7 -0
  45. package/src/index.ts +75 -12
  46. package/src/oauth.ts +659 -0
  47. package/src/postgres.ts +127 -0
  48. package/src/routes.ts +243 -0
  49. package/src/seal.ts +62 -0
  50. package/src/store.ts +118 -0
  51. package/src/transports.ts +7 -0
package/README.md CHANGED
@@ -58,25 +58,64 @@ call runs as the conversation's owner, over a connection of their own:
58
58
 
59
59
  ```ts
60
60
  import { createMcpAuth } from "@haikit/mcp";
61
+ import { pgCredentialStore } from "@haikit/mcp/postgres";
61
62
 
62
- const mcpAuth = createMcpAuth({ userOf: (id) => auth.ownerOf(id) }); // auth from @haikit/auth
63
+ const mcpAuth = createMcpAuth({
64
+ userOf: (id) => auth.ownerOf(id), // auth from @haikit/auth
65
+ // for oauth(): where each user's tokens are kept, sealed, and who's signed in
66
+ store: pgCredentialStore(pool), // or memoryCredentialStore()
67
+ secret: process.env.MCP_SECRET!, // 32 bytes or more; a list to rotate
68
+ redirectUri: "https://app.example.com/mcp/callback",
69
+ requestUser: async (req) => (await auth.user(req))?.id ?? null,
70
+ onConnected: ({ userId, server, conversationId }) => { /* wake the conversation */ },
71
+ });
72
+
73
+ // a token the app already has for each user, or null
63
74
  const tracker = mcpServer({
64
75
  name: "tracker",
65
76
  transport: http("https://mcp.tracker.example/mcp"),
66
- auth: mcpAuth.bearer((userId) => keys.find(userId)), // their token, or null
77
+ auth: mcpAuth.bearer((userId) => keys.find(userId)),
78
+ });
79
+
80
+ // each user connects their own account, with the server's authorization server
81
+ const linear = mcpServer({
82
+ name: "linear",
83
+ transport: http("https://mcp.linear.example/mcp"),
84
+ auth: mcpAuth.oauth({ issuer: "https://linear.example" }), // pin a third party's
85
+ });
86
+
87
+ // the connect, callback and disconnect routes, beside your own
88
+ const mcpRoutes = mcpAuth.handler();
89
+ createServer(async (req, res) => {
90
+ if (await mcpRoutes(req, res)) return;
91
+ // ...
67
92
  });
68
93
  ```
69
94
 
70
95
  A call with no one to run as is refused with reason `user`, and one for a user
71
- with no token with reason `auth`, before anything is sent: it never falls
72
- back to the app's identity. `err.auth.kind` says what the user must do:
73
- `connect`, `reconnect`, or grant more `scope`. A job outside any conversation
74
- passes `{ userId }`.
96
+ with no credential with reason `auth`, before anything is sent: it never
97
+ falls back to the app's identity. `err.auth.kind` says what the user must
98
+ do: `connect`, `reconnect`, or grant more `scope`. For an `oauth()` server,
99
+ `err.auth.connect` is the app's link for it, such as
100
+ `/mcp/connect/linear?conversation=c_123`, to show the user in a surface,
101
+ never to give the model. A job outside any conversation passes `{ userId }`.
102
+
103
+ With `oauth()`, the connect route sends the user to the authorization server
104
+ with PKCE, and the callback stores their tokens sealed with `secret`, bound
105
+ to the user and the server. A 401 refreshes them once, however many calls
106
+ get one. Across app processes sharing the store, one at a time takes the
107
+ store's refresh lease and spends the refresh token, and the others use what
108
+ it stores. Each call reads the store first, so a refresh, a connect or a
109
+ disconnect in another process is seen on the next call. `connections(userId)` lists what a user connected, and
110
+ `disconnect(userId, server?)` revokes and deletes.
111
+ `mcpAuth.sdk(make)` takes one of the SDK's own providers for each user
112
+ instead.
75
113
 
76
114
  `McpTool` isn't a HaiKIT `Tool`, so it can't be passed to
77
115
  `createHai({ tools })`: `npm run typetest` checks that.
78
116
 
79
- Imports only `@haikit/core`, never `@haikit/server`. Built on
117
+ Imports only `@haikit/core`, never `@haikit/server`. `@haikit/mcp/postgres`
118
+ takes any driver with `query(text, params)`, such as a `pg.Pool`. Built on
80
119
  [`@modelcontextprotocol/client`](https://www.npmjs.com/package/@modelcontextprotocol/client),
81
120
  the official TypeScript SDK's client.
82
121
 
package/dist/auth.d.ts CHANGED
@@ -1,10 +1,65 @@
1
- import type { AuthProvider } from "@modelcontextprotocol/client";
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { AuthProvider, FetchLike, OAuthClientProvider } from "@modelcontextprotocol/client";
3
+ import { type Credential } from "./credential.js";
4
+ import { type OAuthServerOptions } from "./oauth.js";
5
+ import { type CredentialStore } from "./store.js";
6
+ export { LookupFailed, Mismatch, NeedsAuth, type Credential } from "./credential.js";
7
+ export type { OAuthServerOptions } from "./oauth.js";
2
8
  export interface McpAuthOptions {
3
9
  /**
4
10
  * Who a conversation belongs to: the user a call made in it runs as. With
5
11
  * @haikit/auth, `(id) => auth.ownerOf(id)`. `null` for no one.
6
12
  */
7
13
  userOf(conversationId: string): string | null | Promise<string | null>;
14
+ /**
15
+ * Where each user's OAuth credentials are kept, sealed:
16
+ * `memoryCredentialStore()`, or `pgCredentialStore(db)` from
17
+ * `@haikit/mcp/postgres`. With `secret` and `redirectUri`, for `oauth()`.
18
+ */
19
+ store?: CredentialStore;
20
+ /**
21
+ * Seals every credential at rest. Several: the first seals, and every one
22
+ * opens, so a secret can be rotated out. Each at least 32 bytes.
23
+ */
24
+ secret?: string | string[];
25
+ /**
26
+ * Where an authorization server sends the browser back, exactly as
27
+ * registered with it: its path is the callback route. https, except on
28
+ * localhost.
29
+ */
30
+ redirectUri?: string;
31
+ /**
32
+ * The signed-in user a request to the routes comes from, or `null`. With
33
+ * @haikit/auth, `async (req) => (await auth.user(req))?.id ?? null`. For
34
+ * `handler()`.
35
+ */
36
+ requestUser?(req: IncomingMessage): string | null | Promise<string | null>;
37
+ /**
38
+ * A user has connected an account, at the callback route. With the
39
+ * conversation the connect link was for, if they own it. What it throws is
40
+ * logged: the user is connected all the same.
41
+ */
42
+ onConnected?(connected: McpConnected): void | Promise<void>;
43
+ /** Where `handler()`'s routes are, and the connect links point. Default `"/mcp"`. */
44
+ basePath?: string;
45
+ /** The app's name, which an authorization server shows when the user connects. Default: the redirect URI's host. */
46
+ clientName?: string;
47
+ }
48
+ /** What `onConnected` is told. */
49
+ export interface McpConnected {
50
+ userId: string;
51
+ server: string;
52
+ /** The conversation the connect link was for: only one the user owns. */
53
+ conversationId?: string;
54
+ }
55
+ /** A server a user has connected an account for, from `connections()`. */
56
+ export interface McpAccount {
57
+ /** The server's `name`. */
58
+ name: string;
59
+ /** The scope granted, or asked for when the authorization server didn't say. */
60
+ scope: string | null;
61
+ /** When the user connected, epoch ms. */
62
+ connectedAt: number;
8
63
  }
9
64
  /** Makes the `auth` of servers whose calls are made as each user. */
10
65
  export interface McpAuth {
@@ -13,64 +68,80 @@ export interface McpAuth {
13
68
  * it, or `null` when the user has none. Sent as `Authorization: Bearer`.
14
69
  */
15
70
  bearer(token: (userId: string) => string | null | Promise<string | null>): McpServerAuth;
71
+ /**
72
+ * Each user connects their account through the connect route, and the
73
+ * store keeps their tokens. Needs `store`, `secret` and `redirectUri`.
74
+ */
75
+ oauth(options?: OAuthServerOptions): McpServerAuth;
76
+ /**
77
+ * One of the SDK's own providers, made for each user: `make(userId)` gives
78
+ * it, or `null` when the user has none. The app owns the whole provider:
79
+ * nothing is stored, and there's no connect route.
80
+ */
81
+ sdk(make: (userId: string) => AuthProvider | OAuthClientProvider | null | Promise<AuthProvider | OAuthClientProvider | null>): McpServerAuth;
82
+ /**
83
+ * The routes, under `basePath`: connect, the callback at the redirect URI's
84
+ * path, disconnect, the user's connections, and the client metadata
85
+ * document. Resolves false for anything else. Needs `requestUser`.
86
+ */
87
+ handler(): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
88
+ /** The `oauth()` servers a user has connected an account for. */
89
+ connections(userId: string): Promise<McpAccount[]>;
90
+ /**
91
+ * Revokes a user's tokens for a server where its authorization server takes
92
+ * it, deletes them, and closes the user's connection; with no `server`, for
93
+ * every server they connected.
94
+ */
95
+ disconnect(userId: string, server?: string): Promise<void>;
16
96
  }
17
97
  /** How a server's calls are made as each user: made by an `McpAuth`, and given to `mcpServer({ auth })`. */
18
98
  export interface McpServerAuth {
19
- readonly kind: "bearer";
99
+ readonly kind: "bearer" | "oauth" | "sdk";
100
+ }
101
+ /** What a server tells its `McpAuth` of itself. */
102
+ export interface Attached {
103
+ name: string;
104
+ timeoutMs: number;
105
+ url: URL;
106
+ fetch: FetchLike | undefined;
107
+ forget(userId: string): Promise<void>;
108
+ }
109
+ /** A server's place on its `McpAuth`. */
110
+ export interface Binding {
111
+ /** The user's credential, for a new connection of theirs. */
112
+ credential(userId: string): Credential;
113
+ /** The connect route's path, for an `oauth()` server: for this conversation, and asking for `scope`. */
114
+ readonly connect: ((conversationId: string | undefined, scope: string | undefined) => string) | null;
115
+ /** Frees the name, for a closed server. */
116
+ detach(): void;
20
117
  }
21
118
  /** What an `McpServerAuth` stands for. */
22
119
  export interface AuthConfig {
23
- readonly kind: "bearer";
120
+ readonly kind: McpServerAuth["kind"];
24
121
  userOf(conversationId: string): string | null | Promise<string | null>;
25
- token(userId: string): string | null | Promise<string | null>;
26
- /** Server names in use with this `McpAuth`: each is a key for its users' credentials. */
27
- readonly names: Set<string>;
122
+ /** Takes the server's name on this `McpAuth`, or throws if another server has it: the name keys its users' credentials. */
123
+ attach(server: Attached): Binding;
28
124
  }
29
125
  /** The config an `McpServerAuth` stands for, or undefined if `createMcpAuth` didn't make it. */
30
126
  export declare const configOf: (auth: unknown) => AuthConfig | undefined;
31
127
  /**
32
128
  * For servers whose calls are made as each user, with that user's own
33
- * credential: `mcpServer({ auth: mcpAuth.bearer(token) })`.
129
+ * credential: `mcpServer({ auth: mcpAuth.oauth() })`.
34
130
  */
35
131
  export declare function createMcpAuth(options: McpAuthOptions): McpAuth;
36
- /**
37
- * The user has no credential, or the server refused theirs: thrown inside a
38
- * connection, where the transport passes it on unchanged, and made an
39
- * `McpError` with reason `auth`.
40
- */
41
- export declare class NeedsAuth extends Error {
42
- readonly kind: "connect" | "reconnect";
43
- readonly name = "NeedsAuth";
44
- constructor(kind: "connect" | "reconnect");
45
- }
46
- /** The app's lookup of a credential failed: made an `McpError` with reason `connection`. */
47
- export declare class LookupFailed extends Error {
48
- readonly name = "LookupFailed";
49
- constructor(cause: unknown);
50
- }
51
- /** One user's credential for one server, and the `AuthProvider` that sends it. */
52
- export interface Credential {
53
- /** Before a connection or call: finds the credential, or throws `NeedsAuth("connect")`, so nothing is sent without one. */
54
- ensure(): Promise<void>;
55
- /** For a connection being closed: stops a lookup on its way, and its timer. */
56
- close(): void;
57
- readonly provider: AuthProvider;
58
- }
59
132
  /** A token from `bearer`'s function, kept until a lookup on a 401 finds another, or none. */
60
133
  export declare class BearerCredential implements Credential {
61
134
  private readonly read;
62
135
  private readonly user;
63
- /** How long `read` may take: a lookup that never answers would hold up every call after it. */
64
- private readonly timeoutMs;
65
136
  private token;
66
137
  /** The lookup on its way, which every caller shares: only it sets `token`. */
67
138
  private looking;
68
- /** Stops the lookup on its way, for `close()`. */
69
- private stop;
139
+ /** Bounds `read`: a lookup that never answers would hold up every call after it. */
140
+ private readonly lookups;
70
141
  constructor(read: (userId: string) => string | null | Promise<string | null>, user: string,
71
- /** How long `read` may take: a lookup that never answers would hold up every call after it. */
142
+ /** How long `read` may take. */
72
143
  timeoutMs: number);
73
- ensure(): Promise<void>;
144
+ ensure(): Promise<boolean>;
74
145
  close(): void;
75
146
  readonly provider: AuthProvider;
76
147
  /** One lookup at a time, for first calls and 401s that come together, so none overwrites another's token. */
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAEjE,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,MAAM,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACxE;AAED,qEAAqE;AACrE,MAAM,WAAW,OAAO;IACtB;;;OAGG;IACH,MAAM,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,aAAa,CAAC;CAC1F;AAED,4GAA4G;AAC5G,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;CACzB;AAED,0CAA0C;AAC1C,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,MAAM,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvE,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9D,yFAAyF;IACzF,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC7B;AAID,gGAAgG;AAChG,eAAO,MAAM,QAAQ,GAAI,MAAM,OAAO,KAAG,UAAU,GAAG,SACqB,CAAC;AAE5E;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAgB9D;AAED;;;;GAIG;AACH,qBAAa,SAAU,SAAQ,KAAK;IAEtB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,WAAW;IADlD,QAAQ,CAAC,IAAI,eAAe;gBACP,IAAI,EAAE,SAAS,GAAG,WAAW;CAGnD;AAED,4FAA4F;AAC5F,qBAAa,YAAa,SAAQ,KAAK;IACrC,QAAQ,CAAC,IAAI,kBAAkB;gBACnB,KAAK,EAAE,OAAO;CAG3B;AAED,kFAAkF;AAClF,MAAM,WAAW,UAAU;IACzB,2HAA2H;IAC3H,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,+EAA+E;IAC/E,KAAK,IAAI,IAAI,CAAC;IACd,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;CACjC;AAED,6FAA6F;AAC7F,qBAAa,gBAAiB,YAAW,UAAU;IAQ/C,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,+FAA+F;IAC/F,OAAO,CAAC,QAAQ,CAAC,SAAS;IAV5B,OAAO,CAAC,KAAK,CAAuB;IACpC,8EAA8E;IAC9E,OAAO,CAAC,OAAO,CAAuC;IACtD,kDAAkD;IAClD,OAAO,CAAC,IAAI,CAA6B;gBAGtB,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,EAChE,IAAI,EAAE,MAAM;IAC7B,+FAA+F;IAC9E,SAAS,EAAE,MAAM;IAG9B,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAK7B,KAAK,IAAI,IAAI;IAMb,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAc7B;IAEF,6GAA6G;IAC7G,OAAO,CAAC,IAAI;YASE,MAAM;CAiBrB"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACjG,OAAO,EAAmC,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,EAAyC,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAG5F,OAAO,EAAiB,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACrF,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAErD,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,MAAM,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvE;;;;OAIG;IACH,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,WAAW,CAAC,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC3E;;;;OAIG;IACH,WAAW,CAAC,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,qFAAqF;IACrF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oHAAoH;IACpH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,kCAAkC;AAClC,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,0EAA0E;AAC1E,MAAM,WAAW,UAAU;IACzB,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,gFAAgF;IAChF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,qEAAqE;AACrE,MAAM,WAAW,OAAO;IACtB;;;OAGG;IACH,MAAM,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,aAAa,CAAC;IACzF;;;OAGG;IACH,KAAK,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAAC;IACnD;;;;OAIG;IACH,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,YAAY,GAAG,mBAAmB,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,GAAG,mBAAmB,GAAG,IAAI,CAAC,GAAG,aAAa,CAAC;IAC7I;;;;OAIG;IACH,OAAO,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3E,iEAAiE;IACjE,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IACnD;;;;OAIG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D;AAED,4GAA4G;AAC5G,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,GAAG,KAAK,CAAC;CAC3C;AAED,mDAAmD;AACnD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,GAAG,CAAC;IACT,KAAK,EAAE,SAAS,GAAG,SAAS,CAAC;IAC7B,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,yCAAyC;AACzC,MAAM,WAAW,OAAO;IACtB,6DAA6D;IAC7D,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IACvC,wGAAwG;IACxG,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,KAAK,MAAM,CAAC,GAAG,IAAI,CAAC;IACrG,2CAA2C;IAC3C,MAAM,IAAI,IAAI,CAAC;CAChB;AAED,0CAA0C;AAC1C,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACrC,MAAM,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvE,2HAA2H;IAC3H,MAAM,CAAC,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC;CACnC;AAID,gGAAgG;AAChG,eAAO,MAAM,QAAQ,GAAI,MAAM,OAAO,KAAG,UAAU,GAAG,SACqB,CAAC;AAM5E;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAqH9D;AAwFD,6FAA6F;AAC7F,qBAAa,gBAAiB,YAAW,UAAU;IAQ/C,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI;IARvB,OAAO,CAAC,KAAK,CAAuB;IACpC,8EAA8E;IAC9E,OAAO,CAAC,OAAO,CAAuC;IACtD,oFAAoF;IACpF,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAGd,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,EAChE,IAAI,EAAE,MAAM;IAC7B,gCAAgC;IAChC,SAAS,EAAE,MAAM;IAKb,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAMhC,KAAK,IAAI,IAAI;IAMb,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAc7B;IAEF,6GAA6G;IAC7G,OAAO,CAAC,IAAI;YASE,MAAM;CASrB"}
package/dist/auth.js CHANGED
@@ -1,72 +1,256 @@
1
+ import { LookupFailed, Lookup, NeedsAuth } from "./credential.js";
2
+ import { OAuthServer, isSecure } from "./oauth.js";
3
+ import { routes } from "./routes.js";
4
+ import { SECRET_BYTES, Sealer } from "./seal.js";
5
+ import { STORE_METHODS } from "./store.js";
6
+ export { LookupFailed, Mismatch, NeedsAuth } from "./credential.js";
1
7
  const configs = new WeakMap();
2
8
  /** The config an `McpServerAuth` stands for, or undefined if `createMcpAuth` didn't make it. */
3
9
  export const configOf = (auth) => typeof auth === "object" && auth !== null ? configs.get(auth) : undefined;
10
+ const BASE_PATH = /^(\/[A-Za-z0-9\-._~!$&'()*+,;=:@%]+)+$/;
11
+ /** What `handler()` answers under `basePath` itself. */
12
+ const ROUTES = ["connect", "disconnect", "connections", "client.json"];
4
13
  /**
5
14
  * For servers whose calls are made as each user, with that user's own
6
- * credential: `mcpServer({ auth: mcpAuth.bearer(token) })`.
15
+ * credential: `mcpServer({ auth: mcpAuth.oauth() })`.
7
16
  */
8
17
  export function createMcpAuth(options) {
9
18
  if (typeof options?.userOf !== "function") {
10
19
  throw new TypeError("createMcpAuth: userOf must be a function, such as (id) => auth.ownerOf(id)");
11
20
  }
12
- const names = new Set();
21
+ for (const name of ["requestUser", "onConnected"]) {
22
+ if (options[name] !== undefined && typeof options[name] !== "function")
23
+ throw new TypeError(`createMcpAuth: ${name} must be a function`);
24
+ }
25
+ const basePath = options.basePath ?? "/mcp";
26
+ if (typeof basePath !== "string" || !BASE_PATH.test(basePath)) {
27
+ throw new TypeError(`createMcpAuth: basePath must be a path such as "/mcp", with no trailing slash (got ${JSON.stringify(basePath)})`);
28
+ }
29
+ // a request's path is read as a URL, which takes out dot segments: one there would match no route
30
+ if (new URL(basePath, "http://localhost").pathname !== basePath) {
31
+ throw new TypeError(`createMcpAuth: basePath can't have a "." or ".." segment (got ${JSON.stringify(basePath)})`);
32
+ }
33
+ const core = oauthCore(options, basePath);
34
+ /** Every server attached, by name: its kind, and its OAuth if it has one. */
35
+ const servers = new Map();
13
36
  const userOf = (conversationId) => options.userOf(conversationId);
37
+ function make(kind, bind) {
38
+ const auth = Object.freeze({ kind });
39
+ configs.set(auth, {
40
+ kind,
41
+ userOf,
42
+ attach(server) {
43
+ if (servers.has(server.name))
44
+ throw new TypeError(`another server from the same createMcpAuth is named "${server.name}"`);
45
+ const bound = bind(server);
46
+ const entry = { forget: server.forget, oauth: bound instanceof OAuthBinding ? bound.oauth : null };
47
+ servers.set(server.name, entry);
48
+ return {
49
+ credential: (user) => bound.credential(user),
50
+ connect: bound.connect,
51
+ detach: () => {
52
+ if (servers.get(server.name) === entry)
53
+ servers.delete(server.name);
54
+ },
55
+ };
56
+ },
57
+ });
58
+ return auth;
59
+ }
60
+ const needsStore = (what) => {
61
+ if (!core)
62
+ throw new TypeError(`${what} needs createMcpAuth({ store, secret, redirectUri })`);
63
+ return core;
64
+ };
14
65
  return Object.freeze({
15
66
  bearer(token) {
16
67
  if (typeof token !== "function") {
17
68
  throw new TypeError("bearer: token must be a function that takes a user id and returns their token, or null");
18
69
  }
19
- const auth = Object.freeze({ kind: "bearer" });
20
- configs.set(auth, { kind: "bearer", userOf, token, names });
21
- return auth;
70
+ return make("bearer", (server) => ({
71
+ credential: (user) => new BearerCredential(token, user, server.timeoutMs),
72
+ connect: null,
73
+ }));
22
74
  },
75
+ oauth(oauthOptions = {}) {
76
+ const ready = needsStore("oauth()");
77
+ const checked = checkOAuth(oauthOptions);
78
+ return make("oauth", (server) => new OAuthBinding(new OAuthServer(ready, checked, server.name, server.url, server.fetch ?? fetch, server.timeoutMs, server.forget), basePath));
79
+ },
80
+ sdk(makeProvider) {
81
+ if (typeof makeProvider !== "function") {
82
+ throw new TypeError("sdk: make must be a function that takes a user id and returns their AuthProvider, or null");
83
+ }
84
+ return make("sdk", (server) => ({
85
+ credential: (user) => new SdkCredential(makeProvider, user, server.timeoutMs),
86
+ connect: null,
87
+ }));
88
+ },
89
+ handler() {
90
+ const ready = needsStore("handler()");
91
+ if (!options.requestUser)
92
+ throw new TypeError("handler() needs createMcpAuth's requestUser: its routes act for the user signed in");
93
+ const requestUser = options.requestUser;
94
+ return routes({
95
+ core: ready,
96
+ basePath,
97
+ requestUser: async (req) => nonEmpty(await requestUser(req)),
98
+ userOf: async (id) => nonEmpty(await options.userOf(id)),
99
+ onConnected: options.onConnected,
100
+ oauth: (name) => servers.get(name)?.oauth ?? undefined,
101
+ connections,
102
+ disconnect,
103
+ });
104
+ },
105
+ connections,
106
+ disconnect,
23
107
  });
108
+ async function connections(userId) {
109
+ const ready = needsStore("connections()");
110
+ if (typeof userId !== "string" || userId === "")
111
+ throw new TypeError("connections() needs a userId");
112
+ const accounts = [];
113
+ for (const name of await ready.store.list(userId)) {
114
+ const held = await servers.get(name)?.oauth?.read(userId);
115
+ if (held)
116
+ accounts.push({ name, scope: held.kept.scope, connectedAt: held.kept.connectedAt });
117
+ }
118
+ return accounts;
119
+ }
120
+ async function disconnect(userId, server) {
121
+ const ready = needsStore("disconnect()");
122
+ if (typeof userId !== "string" || userId === "")
123
+ throw new TypeError("disconnect() needs a userId");
124
+ if (server !== undefined && (typeof server !== "string" || server === ""))
125
+ throw new TypeError("disconnect(): server must be a server's name");
126
+ for (const name of server === undefined ? await ready.store.list(userId) : [server]) {
127
+ const oauth = servers.get(name)?.oauth;
128
+ if (oauth)
129
+ await oauth.disconnect(userId);
130
+ // a server no longer configured: nothing to revoke with, or close
131
+ else
132
+ await ready.store.delete(userId, name);
133
+ }
134
+ }
24
135
  }
25
- /**
26
- * The user has no credential, or the server refused theirs: thrown inside a
27
- * connection, where the transport passes it on unchanged, and made an
28
- * `McpError` with reason `auth`.
29
- */
30
- export class NeedsAuth extends Error {
31
- kind;
32
- name = "NeedsAuth";
33
- constructor(kind) {
34
- super(kind === "connect" ? "the user has no credential" : "the server refused the user's credential");
35
- this.kind = kind;
136
+ /** An `oauth()` server's place on its `McpAuth`. */
137
+ class OAuthBinding {
138
+ oauth;
139
+ basePath;
140
+ constructor(oauth, basePath) {
141
+ this.oauth = oauth;
142
+ this.basePath = basePath;
143
+ }
144
+ credential(user) {
145
+ return this.oauth.credential(user);
146
+ }
147
+ connect = (conversationId, scope) => {
148
+ const query = new URLSearchParams();
149
+ if (conversationId !== undefined)
150
+ query.set("conversation", conversationId);
151
+ if (scope !== undefined)
152
+ query.set("scope", scope);
153
+ const search = query.toString();
154
+ return `${this.basePath}/connect/${encodeURIComponent(this.oauth.name)}${search ? `?${search}` : ""}`;
155
+ };
156
+ }
157
+ /** What `oauth()` needs of `createMcpAuth`'s options, checked: null if none of it is given. */
158
+ function oauthCore(options, basePath) {
159
+ const { store, secret, redirectUri } = options;
160
+ if (store === undefined && secret === undefined && redirectUri === undefined)
161
+ return null;
162
+ if (store === undefined || secret === undefined || redirectUri === undefined) {
163
+ throw new TypeError("createMcpAuth: store, secret and redirectUri go together: oauth() needs all three");
164
+ }
165
+ if (typeof store !== "object" || store === null || STORE_METHODS.some((m) => typeof store[m] !== "function")) {
166
+ throw new TypeError("createMcpAuth: store must be a CredentialStore, such as memoryCredentialStore()");
167
+ }
168
+ const secrets = [secret].flat();
169
+ if (!secrets.length || secrets.some((s) => typeof s !== "string"))
170
+ throw new TypeError("createMcpAuth: secret must be a string, or a list of them");
171
+ if (secrets.some((s) => Buffer.byteLength(s) < SECRET_BYTES))
172
+ throw new TypeError(`createMcpAuth: each secret must be at least ${SECRET_BYTES} bytes`);
173
+ let callback;
174
+ try {
175
+ callback = new URL(redirectUri);
36
176
  }
177
+ catch {
178
+ throw new TypeError("createMcpAuth: redirectUri must be a URL, such as https://app.example.com/mcp/callback");
179
+ }
180
+ if (!isSecure(callback))
181
+ throw new TypeError("createMcpAuth: redirectUri must be https, except on localhost");
182
+ if (callback.hash || callback.search)
183
+ throw new TypeError("createMcpAuth: redirectUri can't have a query or a fragment");
184
+ const path = callback.pathname;
185
+ if (ROUTES.some((route) => path === `${basePath}/${route}` || path.startsWith(`${basePath}/${route}/`))) {
186
+ throw new TypeError(`createMcpAuth: redirectUri comes back to ${path}, a route handler() answers already`);
187
+ }
188
+ const clientName = options.clientName ?? callback.host;
189
+ if (typeof clientName !== "string" || clientName === "")
190
+ throw new TypeError("createMcpAuth: clientName must be a non-empty string");
191
+ return {
192
+ store,
193
+ sealer: new Sealer(secrets),
194
+ redirectUri: callback.href,
195
+ clientMetadata: {
196
+ client_name: clientName,
197
+ redirect_uris: [callback.href],
198
+ grant_types: ["authorization_code", "refresh_token"],
199
+ response_types: ["code"],
200
+ token_endpoint_auth_method: "none",
201
+ },
202
+ // the SDK refuses a client identified by a URL that isn't https: on localhost, it registers one
203
+ clientMetadataUrl: callback.protocol === "https:" ? new URL(`${basePath}/client.json`, callback).href : undefined,
204
+ };
37
205
  }
38
- /** The app's lookup of a credential failed: made an `McpError` with reason `connection`. */
39
- export class LookupFailed extends Error {
40
- name = "LookupFailed";
41
- constructor(cause) {
42
- super("the user's credential could not be looked up", { cause });
206
+ function checkOAuth(options) {
207
+ if (typeof options !== "object" || options === null)
208
+ throw new TypeError("oauth: options must be an object");
209
+ const { issuer, scopes, client } = options;
210
+ if (issuer !== undefined) {
211
+ if (typeof issuer !== "string" || !URL.canParse(issuer))
212
+ throw new TypeError("oauth: issuer must be the authorization server's issuer URL");
213
+ if (!isSecure(issuer))
214
+ throw new TypeError("oauth: issuer must be https, except on localhost");
215
+ }
216
+ if (scopes !== undefined && (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string" || !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(s)))) {
217
+ throw new TypeError("oauth: scopes must be a list of OAuth scopes, such as [\"repo\"], each without spaces");
43
218
  }
219
+ if (client !== undefined) {
220
+ if (typeof client?.id !== "string" || client.id === "" || (client.secret !== undefined && typeof client.secret !== "string")) {
221
+ throw new TypeError("oauth: client must be { id, secret? }, as registered with the authorization server");
222
+ }
223
+ if (issuer === undefined)
224
+ throw new TypeError("oauth: a client needs the issuer it was registered with");
225
+ }
226
+ return Object.freeze({ ...options, ...(scopes ? { scopes: [...scopes] } : {}), ...(client ? { client: { ...client } } : {}) });
44
227
  }
228
+ const nonEmpty = (value) => (typeof value === "string" && value !== "" ? value : null);
45
229
  /** A token from `bearer`'s function, kept until a lookup on a 401 finds another, or none. */
46
230
  export class BearerCredential {
47
231
  read;
48
232
  user;
49
- timeoutMs;
50
233
  token = null;
51
234
  /** The lookup on its way, which every caller shares: only it sets `token`. */
52
235
  looking = null;
53
- /** Stops the lookup on its way, for `close()`. */
54
- stop = null;
236
+ /** Bounds `read`: a lookup that never answers would hold up every call after it. */
237
+ lookups;
55
238
  constructor(read, user,
56
- /** How long `read` may take: a lookup that never answers would hold up every call after it. */
239
+ /** How long `read` may take. */
57
240
  timeoutMs) {
58
241
  this.read = read;
59
242
  this.user = user;
60
- this.timeoutMs = timeoutMs;
243
+ this.lookups = new Lookup(timeoutMs, "token");
61
244
  }
62
245
  async ensure() {
63
246
  if (this.token === null)
64
247
  await this.look();
65
248
  if (this.token === null)
66
249
  throw new NeedsAuth("connect");
250
+ return false;
67
251
  }
68
252
  close() {
69
- this.stop?.();
253
+ this.lookups.stop();
70
254
  }
71
255
  // The transport asks for the token before every request, and on a 401
72
256
  // calls onUnauthorized, then tries once more with what it finds.
@@ -97,23 +281,54 @@ export class BearerCredential {
97
281
  return this.looking;
98
282
  }
99
283
  async lookup() {
100
- let timer;
101
- const cut = new Promise((_, reject) => {
102
- timer = setTimeout(() => reject(new Error(`no token within ${this.timeoutMs} ms`)), this.timeoutMs);
103
- this.stop = () => reject(new Error("closed while the token was being looked up"));
104
- });
105
284
  let token;
106
285
  try {
107
- token = await Promise.race([Promise.resolve().then(() => this.read(this.user)), cut]);
286
+ token = await this.lookups.run(() => this.read(this.user));
108
287
  }
109
288
  catch (err) {
110
289
  throw new LookupFailed(err);
111
290
  }
112
- finally {
113
- clearTimeout(timer);
114
- this.stop = null;
115
- }
116
291
  return typeof token === "string" && token !== "" ? token : null;
117
292
  }
118
293
  }
294
+ /** A provider from `sdk()`'s function, made once for each connection: the app's provider does the rest. */
295
+ class SdkCredential {
296
+ make;
297
+ user;
298
+ made = null;
299
+ looking = null;
300
+ lookups;
301
+ constructor(make, user, timeoutMs) {
302
+ this.make = make;
303
+ this.user = user;
304
+ this.lookups = new Lookup(timeoutMs, "provider");
305
+ }
306
+ async ensure() {
307
+ if (this.made === null) {
308
+ this.looking ??= this.lookups
309
+ .run(() => this.make(this.user))
310
+ .then((made) => {
311
+ this.made = typeof made === "object" && made !== null ? made : null;
312
+ }, (err) => {
313
+ throw new LookupFailed(err);
314
+ })
315
+ .finally(() => {
316
+ this.looking = null;
317
+ });
318
+ await this.looking;
319
+ }
320
+ if (this.made === null)
321
+ throw new NeedsAuth("connect");
322
+ return false;
323
+ }
324
+ close() {
325
+ this.lookups.stop();
326
+ }
327
+ get provider() {
328
+ // never asked for before ensure() has made it: a connection dials only after
329
+ if (this.made === null)
330
+ throw new NeedsAuth("connect");
331
+ return this.made;
332
+ }
333
+ }
119
334
  //# sourceMappingURL=auth.js.map