@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.
- package/README.md +46 -7
- package/dist/auth.d.ts +107 -36
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +252 -37
- package/dist/auth.js.map +1 -1
- package/dist/credential.d.ts +55 -0
- package/dist/credential.d.ts.map +1 -0
- package/dist/credential.js +62 -0
- package/dist/credential.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +66 -9
- package/dist/index.js.map +1 -1
- package/dist/oauth.d.ts +193 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +558 -0
- package/dist/oauth.js.map +1 -0
- package/dist/postgres.d.ts +25 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +97 -0
- package/dist/postgres.js.map +1 -0
- package/dist/routes.d.ts +17 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +228 -0
- package/dist/routes.js.map +1 -0
- package/dist/seal.d.ts +25 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +60 -0
- package/dist/seal.js.map +1 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +83 -0
- package/dist/store.js.map +1 -0
- package/dist/transports.d.ts +5 -1
- package/dist/transports.d.ts.map +1 -1
- package/dist/transports.js +2 -0
- package/dist/transports.js.map +1 -1
- package/package.json +6 -2
- package/src/auth.ts +354 -48
- package/src/credential.ts +78 -0
- package/src/errors.ts +7 -0
- package/src/index.ts +75 -12
- package/src/oauth.ts +659 -0
- package/src/postgres.ts +127 -0
- package/src/routes.ts +243 -0
- package/src/seal.ts +62 -0
- package/src/store.ts +118 -0
- 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({
|
|
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)),
|
|
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
|
|
72
|
-
back to the app's identity. `err.auth.kind` says what the user must
|
|
73
|
-
`connect`, `reconnect`, or grant more `scope`.
|
|
74
|
-
|
|
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`.
|
|
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 {
|
|
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: "
|
|
120
|
+
readonly kind: McpServerAuth["kind"];
|
|
24
121
|
userOf(conversationId: string): string | null | Promise<string | null>;
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
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
|
-
/**
|
|
69
|
-
private
|
|
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
|
|
142
|
+
/** How long `read` may take. */
|
|
72
143
|
timeoutMs: number);
|
|
73
|
-
ensure(): Promise<
|
|
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. */
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
|
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.
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
|
239
|
+
/** How long `read` may take. */
|
|
57
240
|
timeoutMs) {
|
|
58
241
|
this.read = read;
|
|
59
242
|
this.user = user;
|
|
60
|
-
this.
|
|
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
|
|
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
|