@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/src/postgres.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @haikit/mcp/postgres — each user's credentials in Postgres, shared by every
|
|
3
|
+
* server. As in @haikit/postgres, every method is one statement, and the
|
|
4
|
+
* driver is yours.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CredentialStore } from "./store.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Anything that runs a parameterised statement and returns rows: a `pg.Pool`,
|
|
11
|
+
* a `pg.Client` and PGlite all fit as they are. The same shape as
|
|
12
|
+
* @haikit/postgres's and @haikit/auth/postgres's `Queryable`, so one pool
|
|
13
|
+
* serves all three.
|
|
14
|
+
*/
|
|
15
|
+
export interface Queryable {
|
|
16
|
+
query(text: string, params?: unknown[]): Promise<{ rows: any[] }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The schema, one statement per entry. `migrate()` runs these; export them to
|
|
21
|
+
* your own migration tool instead if you have one.
|
|
22
|
+
*/
|
|
23
|
+
export const schema: readonly string[] = [
|
|
24
|
+
`CREATE TABLE IF NOT EXISTS haikit_mcp_credentials (
|
|
25
|
+
user_id text NOT NULL,
|
|
26
|
+
server text NOT NULL,
|
|
27
|
+
value text NOT NULL,
|
|
28
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
29
|
+
lease_holder text,
|
|
30
|
+
lease_until timestamptz,
|
|
31
|
+
PRIMARY KEY (user_id, server)
|
|
32
|
+
)`,
|
|
33
|
+
`CREATE TABLE IF NOT EXISTS haikit_mcp_clients (
|
|
34
|
+
server text NOT NULL,
|
|
35
|
+
issuer text NOT NULL,
|
|
36
|
+
value text NOT NULL,
|
|
37
|
+
PRIMARY KEY (server, issuer)
|
|
38
|
+
)`,
|
|
39
|
+
`CREATE TABLE IF NOT EXISTS haikit_mcp_flows (
|
|
40
|
+
state text PRIMARY KEY,
|
|
41
|
+
value text NOT NULL,
|
|
42
|
+
expires_at timestamptz NOT NULL
|
|
43
|
+
)`,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export async function migrate(db: Queryable): Promise<void> {
|
|
47
|
+
for (const statement of schema) await db.query(statement);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function pgCredentialStore(db: Queryable): CredentialStore {
|
|
51
|
+
return {
|
|
52
|
+
async get(userId, server) {
|
|
53
|
+
const { rows } = await db.query(`SELECT value FROM haikit_mcp_credentials WHERE user_id = $1 AND server = $2`, [userId, server]);
|
|
54
|
+
return rows[0]?.value ?? null;
|
|
55
|
+
},
|
|
56
|
+
async put(userId, server, value, previous) {
|
|
57
|
+
// One statement each: the check of what the row holds and the write
|
|
58
|
+
// can't come apart, so two servers can't both win.
|
|
59
|
+
const { rows } =
|
|
60
|
+
previous === null
|
|
61
|
+
? await db.query(
|
|
62
|
+
`INSERT INTO haikit_mcp_credentials (user_id, server, value) VALUES ($1, $2, $3)
|
|
63
|
+
ON CONFLICT (user_id, server) DO NOTHING
|
|
64
|
+
RETURNING 1 AS written`,
|
|
65
|
+
[userId, server, value],
|
|
66
|
+
)
|
|
67
|
+
: await db.query(
|
|
68
|
+
`UPDATE haikit_mcp_credentials SET value = $3, updated_at = now(), lease_holder = NULL, lease_until = NULL
|
|
69
|
+
WHERE user_id = $1 AND server = $2 AND value = $4
|
|
70
|
+
RETURNING 1 AS written`,
|
|
71
|
+
[userId, server, value, previous],
|
|
72
|
+
);
|
|
73
|
+
return rows.length === 1;
|
|
74
|
+
},
|
|
75
|
+
async delete(userId, server, previous) {
|
|
76
|
+
if (server === undefined) {
|
|
77
|
+
await db.query(`DELETE FROM haikit_mcp_credentials WHERE user_id = $1`, [userId]);
|
|
78
|
+
} else if (previous === undefined) {
|
|
79
|
+
await db.query(`DELETE FROM haikit_mcp_credentials WHERE user_id = $1 AND server = $2`, [userId, server]);
|
|
80
|
+
} else {
|
|
81
|
+
await db.query(`DELETE FROM haikit_mcp_credentials WHERE user_id = $1 AND server = $2 AND value = $3`, [userId, server, previous]);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async list(userId) {
|
|
85
|
+
const { rows } = await db.query(`SELECT server FROM haikit_mcp_credentials WHERE user_id = $1 ORDER BY server`, [userId]);
|
|
86
|
+
return rows.map((row) => row.server as string);
|
|
87
|
+
},
|
|
88
|
+
async lease(userId, server, previous, holder, ms) {
|
|
89
|
+
// by the database's clock, so servers whose clocks differ agree when it lapses
|
|
90
|
+
const { rows } = await db.query(
|
|
91
|
+
`UPDATE haikit_mcp_credentials SET lease_holder = $4, lease_until = now() + $5::float8 * interval '1 millisecond'
|
|
92
|
+
WHERE user_id = $1 AND server = $2 AND value = $3
|
|
93
|
+
AND (lease_until IS NULL OR lease_until <= now() OR lease_holder = $4)
|
|
94
|
+
RETURNING 1 AS leased`,
|
|
95
|
+
[userId, server, previous, holder, ms],
|
|
96
|
+
);
|
|
97
|
+
return rows.length === 1;
|
|
98
|
+
},
|
|
99
|
+
async getClient(server, issuer) {
|
|
100
|
+
const { rows } = await db.query(`SELECT value FROM haikit_mcp_clients WHERE server = $1 AND issuer = $2`, [server, issuer]);
|
|
101
|
+
return rows[0]?.value ?? null;
|
|
102
|
+
},
|
|
103
|
+
async putClient(server, issuer, value) {
|
|
104
|
+
await db.query(
|
|
105
|
+
`INSERT INTO haikit_mcp_clients (server, issuer, value) VALUES ($1, $2, $3)
|
|
106
|
+
ON CONFLICT (server, issuer) DO UPDATE SET value = EXCLUDED.value`,
|
|
107
|
+
[server, issuer, value],
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
async putFlow(state, value, expiresAt) {
|
|
111
|
+
// flows never taken go as new ones come, in the same statement
|
|
112
|
+
await db.query(
|
|
113
|
+
`WITH gone AS (DELETE FROM haikit_mcp_flows WHERE expires_at <= now())
|
|
114
|
+
INSERT INTO haikit_mcp_flows (state, value, expires_at) VALUES ($1, $2, to_timestamp($3::float8 / 1000))`,
|
|
115
|
+
[state, value, expiresAt],
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
async takeFlow(state) {
|
|
119
|
+
// taken once: a second take of the same state finds nothing
|
|
120
|
+
const { rows } = await db.query(
|
|
121
|
+
`DELETE FROM haikit_mcp_flows WHERE state = $1 RETURNING value, expires_at > now() AS live`,
|
|
122
|
+
[state],
|
|
123
|
+
);
|
|
124
|
+
return rows[0]?.live ? rows[0].value : null;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import type { McpAccount, McpConnected } from "./auth.js";
|
|
4
|
+
import { takeFlow, type OAuthCore, type OAuthServer } from "./oauth.js";
|
|
5
|
+
|
|
6
|
+
export interface RoutesContext {
|
|
7
|
+
core: OAuthCore;
|
|
8
|
+
basePath: string;
|
|
9
|
+
/** The user signed in, or null. */
|
|
10
|
+
requestUser(req: IncomingMessage): Promise<string | null>;
|
|
11
|
+
userOf(conversationId: string): Promise<string | null>;
|
|
12
|
+
onConnected: ((connected: McpConnected) => void | Promise<void>) | undefined;
|
|
13
|
+
/** The `oauth()` server of this name, if there is one. */
|
|
14
|
+
oauth(name: string): OAuthServer | undefined;
|
|
15
|
+
connections(userId: string): Promise<McpAccount[]>;
|
|
16
|
+
disconnect(userId: string, server?: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Why the done page says the user isn't connected. */
|
|
20
|
+
type Failure = "signed_out" | "access_denied" | "expired" | "refused" | "failed";
|
|
21
|
+
|
|
22
|
+
/** The done page's one line, for a browser with no opener to tell. */
|
|
23
|
+
const LINES: Record<Failure | "ok", string> = {
|
|
24
|
+
ok: "Connected. You can close this window.",
|
|
25
|
+
signed_out: "Not connected: sign in, then try again.",
|
|
26
|
+
access_denied: "Not connected: access wasn't allowed.",
|
|
27
|
+
expired: "Not connected: this link has expired. Try again.",
|
|
28
|
+
refused: "Not connected: you're signed in as someone else.",
|
|
29
|
+
failed: "Not connected: something went wrong. Try again.",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The done page's one script, which its CSP allows by hash: it tells the window that opened it, at this origin, and closes. */
|
|
33
|
+
const SCRIPT =
|
|
34
|
+
'const m=document.getElementById("m");if(window.opener){window.opener.postMessage(JSON.parse(m.dataset.message),location.origin);window.close()}';
|
|
35
|
+
const CSP = `default-src 'none'; script-src 'sha256-${createHash("sha256").update(SCRIPT).digest("base64")}'; frame-ancestors 'none'`;
|
|
36
|
+
|
|
37
|
+
/** The longest `scope` a connect link may ask for. */
|
|
38
|
+
const SCOPE_LENGTH = 1_000;
|
|
39
|
+
/** RFC 6749's scope: tokens of printable ASCII but `"` and `\`, one space apart. */
|
|
40
|
+
const SCOPE = /^[\x21\x23-\x5b\x5d-\x7e]+( [\x21\x23-\x5b\x5d-\x7e]+)*$/;
|
|
41
|
+
/** The longest `conversation` a connect link may name. */
|
|
42
|
+
const CONVERSATION_LENGTH = 512;
|
|
43
|
+
|
|
44
|
+
const SIGNED_OUT = "sign in to continue";
|
|
45
|
+
const CROSS_SITE = "this request came from another site";
|
|
46
|
+
const NO_SERVER = "no server of that name connects with OAuth";
|
|
47
|
+
|
|
48
|
+
type Headers = Record<string, string | string[] | undefined>;
|
|
49
|
+
const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value);
|
|
50
|
+
|
|
51
|
+
export function routes(ctx: RoutesContext): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
|
52
|
+
const { core, basePath } = ctx;
|
|
53
|
+
const callbackPath = new URL(core.redirectUri).pathname;
|
|
54
|
+
// the redirect URI's origin is this site's, whatever Host a proxy passes on
|
|
55
|
+
const siteOrigin = new URL(core.redirectUri).origin;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a request came from a page of this site, as @haikit/auth checks
|
|
59
|
+
* its sign-out: a browser always sends `Origin` on a POST from a page, and
|
|
60
|
+
* a page can't forge it, nor `Host`. A client that isn't a browser sends
|
|
61
|
+
* no `Origin`, and passes.
|
|
62
|
+
*/
|
|
63
|
+
function fromThisSite(headers: Headers): boolean {
|
|
64
|
+
const origin = first(headers.origin);
|
|
65
|
+
if (origin === undefined) {
|
|
66
|
+
const site = first(headers["sec-fetch-site"]);
|
|
67
|
+
return site === undefined || site === "same-origin" || site === "none";
|
|
68
|
+
}
|
|
69
|
+
if (origin === siteOrigin) return true;
|
|
70
|
+
try {
|
|
71
|
+
return new URL(origin).host === first(headers.host);
|
|
72
|
+
} catch {
|
|
73
|
+
return false; // "null", from a sandboxed frame or a file
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `GET {basePath}/connect/{server}`: off to the authorization server, with the flow kept meanwhile. */
|
|
78
|
+
async function connect(name: string, url: URL, req: IncomingMessage, res: ServerResponse) {
|
|
79
|
+
const oauth = ctx.oauth(name);
|
|
80
|
+
const user = await ctx.requestUser(req);
|
|
81
|
+
if (user === null) return done(res, oauth && name, "signed_out");
|
|
82
|
+
if (!oauth) return send(res, 404, { error: NO_SERVER });
|
|
83
|
+
let conversation = url.searchParams.get("conversation") || null;
|
|
84
|
+
if (conversation !== null) {
|
|
85
|
+
// kept only for its owner, so a connect wakes no one else's conversation
|
|
86
|
+
try {
|
|
87
|
+
if (conversation.length > CONVERSATION_LENGTH || (await ctx.userOf(conversation)) !== user) conversation = null;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`@haikit/mcp: ${name}: the owner of the conversation a connect link names couldn't be looked up, so it's dropped:`, err);
|
|
90
|
+
conversation = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const scope = url.searchParams.get("scope") || undefined;
|
|
94
|
+
if (scope !== undefined && (scope.length > SCOPE_LENGTH || !SCOPE.test(scope))) {
|
|
95
|
+
console.error(`@haikit/mcp: ${name}: a connect link asked for a scope that isn't an OAuth scope`);
|
|
96
|
+
return done(res, name, "failed");
|
|
97
|
+
}
|
|
98
|
+
let to: URL;
|
|
99
|
+
try {
|
|
100
|
+
to = await oauth.start(user, conversation, scope);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`@haikit/mcp: ${name}: connecting user ${user} couldn't start:`, err);
|
|
103
|
+
return done(res, name, "failed");
|
|
104
|
+
}
|
|
105
|
+
res.writeHead(303, { location: to.href, "cache-control": "no-store" }).end();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The redirect URI: the code exchanged for the user's tokens, and the done page. */
|
|
109
|
+
async function callback(url: URL, req: IncomingMessage, res: ServerResponse) {
|
|
110
|
+
const state = url.searchParams.get("state");
|
|
111
|
+
// used up whatever comes next, so a link sent to someone else connects no one
|
|
112
|
+
const flow = state ? await takeFlow(core, state) : null;
|
|
113
|
+
if (!flow) return done(res, undefined, "expired");
|
|
114
|
+
const name = flow.server;
|
|
115
|
+
const user = await ctx.requestUser(req);
|
|
116
|
+
if (user === null) return done(res, name, "signed_out");
|
|
117
|
+
if (user !== flow.user) return done(res, name, "refused");
|
|
118
|
+
const error = url.searchParams.get("error");
|
|
119
|
+
if (error === "access_denied") return done(res, name, "access_denied");
|
|
120
|
+
const code = url.searchParams.get("code");
|
|
121
|
+
const oauth = ctx.oauth(name);
|
|
122
|
+
if (error !== null || !code || !oauth) {
|
|
123
|
+
console.error(
|
|
124
|
+
`@haikit/mcp: ${name}: connecting user ${user} failed: ${
|
|
125
|
+
!oauth ? "the server is no longer configured" : error !== null ? `the authorization server answered ${JSON.stringify(error)}` : "the answer has no code"
|
|
126
|
+
}`,
|
|
127
|
+
);
|
|
128
|
+
return done(res, name, "failed");
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await oauth.finish(flow, code, url.searchParams.get("iss") ?? undefined);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error(`@haikit/mcp: ${name}: connecting user ${user} failed:`, err);
|
|
134
|
+
return done(res, name, "failed");
|
|
135
|
+
}
|
|
136
|
+
if (ctx.onConnected) {
|
|
137
|
+
try {
|
|
138
|
+
await ctx.onConnected({ userId: user, server: name, ...(flow.conversation === null ? {} : { conversationId: flow.conversation }) });
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error(`@haikit/mcp: ${name}: onConnected threw, and user ${user} is connected all the same:`, err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
done(res, name, null);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return async function handle(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
147
|
+
let url: URL;
|
|
148
|
+
try {
|
|
149
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const { pathname } = url;
|
|
154
|
+
const method = req.method;
|
|
155
|
+
const page = async (answer: () => Promise<void>) => {
|
|
156
|
+
try {
|
|
157
|
+
await answer();
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error("@haikit/mcp: a request failed:", err);
|
|
160
|
+
if (!res.headersSent) done(res, undefined, "failed");
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
};
|
|
164
|
+
const api = async (answer: () => Promise<void>) => {
|
|
165
|
+
try {
|
|
166
|
+
await answer();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// as nodeHandler does: answered and logged, never thrown on to the server
|
|
169
|
+
console.error("@haikit/mcp: a request failed:", err);
|
|
170
|
+
if (!res.headersSent) send(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (pathname === callbackPath && method === "GET") return page(() => callback(url, req, res));
|
|
176
|
+
if (!pathname.startsWith(`${basePath}/`)) return false;
|
|
177
|
+
const rest = pathname.slice(basePath.length + 1);
|
|
178
|
+
|
|
179
|
+
if (rest === "connections" && method === "GET") {
|
|
180
|
+
return api(async () => {
|
|
181
|
+
const user = await ctx.requestUser(req);
|
|
182
|
+
if (user === null) return send(res, 401, { error: SIGNED_OUT });
|
|
183
|
+
send(res, 200, { servers: await ctx.connections(user) });
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (rest === "client.json" && method === "GET" && core.clientMetadataUrl !== undefined) {
|
|
187
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "max-age=3600" });
|
|
188
|
+
res.end(JSON.stringify({ client_id: core.clientMetadataUrl, ...core.clientMetadata }));
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const at = rest.indexOf("/");
|
|
193
|
+
const route = rest.slice(0, at);
|
|
194
|
+
const segment = rest.slice(at + 1);
|
|
195
|
+
if (at < 0 || segment === "" || segment.includes("/")) return false;
|
|
196
|
+
let name: string;
|
|
197
|
+
try {
|
|
198
|
+
name = decodeURIComponent(segment);
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
if (route === "connect" && method === "GET") return page(() => connect(name, url, req, res));
|
|
203
|
+
if (route === "disconnect" && method === "POST") {
|
|
204
|
+
return api(async () => {
|
|
205
|
+
if (!fromThisSite(req.headers)) return send(res, 403, { error: CROSS_SITE });
|
|
206
|
+
const user = await ctx.requestUser(req);
|
|
207
|
+
if (user === null) return send(res, 401, { error: SIGNED_OUT });
|
|
208
|
+
if (!ctx.oauth(name)) return send(res, 404, { error: NO_SERVER });
|
|
209
|
+
await ctx.disconnect(user, name);
|
|
210
|
+
res.writeHead(204, { "cache-control": "no-store" }).end();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The done page: it posts `{ type: "haikit:mcp", server, ok, error? }` to
|
|
219
|
+
* the window that opened it, at its own origin, and closes. With no opener,
|
|
220
|
+
* such as a blocked popup, it shows one line and a link back to `/`.
|
|
221
|
+
*/
|
|
222
|
+
function done(res: ServerResponse, server: string | undefined, failure: Failure | null) {
|
|
223
|
+
const message = { type: "haikit:mcp", ...(server === undefined ? {} : { server }), ok: failure === null, ...(failure === null ? {} : { error: failure }) };
|
|
224
|
+
const line = LINES[failure ?? "ok"];
|
|
225
|
+
res.writeHead(200, {
|
|
226
|
+
"content-type": "text/html; charset=utf-8",
|
|
227
|
+
"content-security-policy": CSP,
|
|
228
|
+
"cache-control": "no-store",
|
|
229
|
+
"referrer-policy": "no-referrer",
|
|
230
|
+
});
|
|
231
|
+
res.end(
|
|
232
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(line)}</title></head>` +
|
|
233
|
+
`<body><p id="m" data-message="${escape(JSON.stringify(message))}">${escape(line)}</p><p><a href="/">Back</a></p><script>${SCRIPT}</script></body></html>`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const escape = (text: string) =>
|
|
238
|
+
text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
239
|
+
|
|
240
|
+
function send(res: ServerResponse, status: number, body: unknown) {
|
|
241
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
242
|
+
res.end(JSON.stringify(body));
|
|
243
|
+
}
|
package/src/seal.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** The shortest secret that seals, in bytes. */
|
|
4
|
+
export const SECRET_BYTES = 32;
|
|
5
|
+
const INFO = "haikit/mcp credentials";
|
|
6
|
+
const IV_BYTES = 12;
|
|
7
|
+
const TAG_BYTES = 16;
|
|
8
|
+
/** Marks the format, so another can follow it. */
|
|
9
|
+
const PREFIX = "v1.";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a sealed value is bound to: the row it's kept in. A value copied to
|
|
13
|
+
* another row doesn't open there, as another user's credential, another
|
|
14
|
+
* server's client, or another flow.
|
|
15
|
+
*/
|
|
16
|
+
export const binding = {
|
|
17
|
+
credential: (userId: string, server: string) => JSON.stringify(["credential", userId, server]),
|
|
18
|
+
client: (server: string, issuer: string) => JSON.stringify(["client", server, issuer]),
|
|
19
|
+
flow: (state: string) => JSON.stringify(["flow", state]),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* AES-256-GCM, with a key from each secret by HKDF-SHA256. The first secret
|
|
24
|
+
* seals, and every one opens, so a secret can be rotated out: add the new one
|
|
25
|
+
* first, and drop the old once nothing sealed with it is kept.
|
|
26
|
+
*/
|
|
27
|
+
export class Sealer {
|
|
28
|
+
private readonly keys: Buffer[];
|
|
29
|
+
|
|
30
|
+
constructor(secrets: readonly string[]) {
|
|
31
|
+
this.keys = secrets.map((secret) => Buffer.from(hkdfSync("sha256", secret, new Uint8Array(0), INFO, 32)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
seal(value: unknown, bound: string): string {
|
|
35
|
+
const iv = randomBytes(IV_BYTES);
|
|
36
|
+
const cipher = createCipheriv("aes-256-gcm", this.keys[0], iv);
|
|
37
|
+
cipher.setAAD(Buffer.from(bound));
|
|
38
|
+
const sealed = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]);
|
|
39
|
+
return PREFIX + Buffer.concat([iv, cipher.getAuthTag(), sealed]).toString("base64url");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What `seal` was given, if one of the secrets sealed it for `bound`; else null. */
|
|
43
|
+
open(sealed: string, bound: string): unknown {
|
|
44
|
+
if (typeof sealed !== "string" || !sealed.startsWith(PREFIX)) return null;
|
|
45
|
+
const bytes = Buffer.from(sealed.slice(PREFIX.length), "base64url");
|
|
46
|
+
if (bytes.length < IV_BYTES + TAG_BYTES) return null;
|
|
47
|
+
const iv = bytes.subarray(0, IV_BYTES);
|
|
48
|
+
const tag = bytes.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
|
|
49
|
+
const body = bytes.subarray(IV_BYTES + TAG_BYTES);
|
|
50
|
+
for (const key of this.keys) {
|
|
51
|
+
try {
|
|
52
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
53
|
+
decipher.setAAD(Buffer.from(bound));
|
|
54
|
+
decipher.setAuthTag(tag);
|
|
55
|
+
return JSON.parse(Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8"));
|
|
56
|
+
} catch {
|
|
57
|
+
// sealed with another secret, or for another row: the next secret may open it
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each user's credentials are kept, with the OAuth clients registered
|
|
3
|
+
* for them and the connect flows on their way. `memoryCredentialStore` for
|
|
4
|
+
* one process; `pgCredentialStore` from `@haikit/mcp/postgres` for several.
|
|
5
|
+
*
|
|
6
|
+
* Every value is an opaque string, sealed before the store sees it: a leaked
|
|
7
|
+
* table or backup holds no usable token. Two sealings of the same thing never
|
|
8
|
+
* give the same string, so a value tells one write from another.
|
|
9
|
+
*/
|
|
10
|
+
export interface CredentialStore {
|
|
11
|
+
/** A user's sealed credential for a server, or null. */
|
|
12
|
+
get(userId: string, server: string): Promise<string | null>;
|
|
13
|
+
/**
|
|
14
|
+
* Writes a user's credential for a server if the row still holds
|
|
15
|
+
* `previous` (null: no row yet), and resolves true. False if another write
|
|
16
|
+
* came first: the check and the write must be one atomic step.
|
|
17
|
+
*/
|
|
18
|
+
put(userId: string, server: string, value: string, previous: string | null): Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Deletes one server's credential, or with no `server`, all of the user's.
|
|
21
|
+
* With `previous`, only if the row still holds it.
|
|
22
|
+
*/
|
|
23
|
+
delete(userId: string, server?: string, previous?: string): Promise<void>;
|
|
24
|
+
/** The servers a user has a credential for. */
|
|
25
|
+
list(userId: string): Promise<string[]>;
|
|
26
|
+
/**
|
|
27
|
+
* The refresh lease of a user's credential for a server, so one process at
|
|
28
|
+
* a time spends its refresh token. Taken for `ms`, by the store's clock, if
|
|
29
|
+
* the row still holds `previous` and no lease but `holder`'s is held: true.
|
|
30
|
+
* Else false, changing nothing. `ms` 0 gives `holder`'s back. A write or a
|
|
31
|
+
* delete of the row ends its lease; a lease is no write.
|
|
32
|
+
*/
|
|
33
|
+
lease(userId: string, server: string, previous: string, holder: string, ms: number): Promise<boolean>;
|
|
34
|
+
/** A registered OAuth client, per server and authorization server, shared by every user. */
|
|
35
|
+
getClient(server: string, issuer: string): Promise<string | null>;
|
|
36
|
+
putClient(server: string, issuer: string, value: string): Promise<void>;
|
|
37
|
+
/** A connect flow on its way, kept until it's taken or `expiresAt` (epoch ms) passes. */
|
|
38
|
+
putFlow(state: string, value: string, expiresAt: number): Promise<void>;
|
|
39
|
+
/** Removes and returns a flow, once: null when there's none, or it has expired. */
|
|
40
|
+
takeFlow(state: string): Promise<string | null>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The methods a `CredentialStore` has, to check one an app passes. */
|
|
44
|
+
export const STORE_METHODS = ["get", "put", "delete", "list", "lease", "getClient", "putClient", "putFlow", "takeFlow"] as const;
|
|
45
|
+
|
|
46
|
+
/** The most flows `memoryCredentialStore` keeps: past it, the oldest goes. */
|
|
47
|
+
const MEMORY_FLOWS = 10_000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Credentials in this process's memory. Gone on a restart, and not shared:
|
|
51
|
+
* with several servers, use `pgCredentialStore`. Keeps the latest 10,000
|
|
52
|
+
* flows on their way.
|
|
53
|
+
*/
|
|
54
|
+
export function memoryCredentialStore(): CredentialStore {
|
|
55
|
+
const credentials = new Map<string, Map<string, string>>();
|
|
56
|
+
/** Each credential's refresh lease, by user and server. */
|
|
57
|
+
const leases = new Map<string, { holder: string; until: number }>();
|
|
58
|
+
const clients = new Map<string, string>();
|
|
59
|
+
const flows = new Map<string, { value: string; expiresAt: number }>();
|
|
60
|
+
const key = (a: string, b: string) => JSON.stringify([a, b]);
|
|
61
|
+
return {
|
|
62
|
+
async get(userId, server) {
|
|
63
|
+
return credentials.get(userId)?.get(server) ?? null;
|
|
64
|
+
},
|
|
65
|
+
async put(userId, server, value, previous) {
|
|
66
|
+
const servers = credentials.get(userId) ?? new Map<string, string>();
|
|
67
|
+
if ((servers.get(server) ?? null) !== previous) return false;
|
|
68
|
+
servers.set(server, value);
|
|
69
|
+
credentials.set(userId, servers);
|
|
70
|
+
leases.delete(key(userId, server));
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
async delete(userId, server, previous) {
|
|
74
|
+
const servers = credentials.get(userId);
|
|
75
|
+
if (!servers) return;
|
|
76
|
+
if (server === undefined) {
|
|
77
|
+
for (const name of servers.keys()) leases.delete(key(userId, name));
|
|
78
|
+
credentials.delete(userId);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (previous !== undefined && servers.get(server) !== previous) return;
|
|
82
|
+
servers.delete(server);
|
|
83
|
+
leases.delete(key(userId, server));
|
|
84
|
+
if (!servers.size) credentials.delete(userId);
|
|
85
|
+
},
|
|
86
|
+
async list(userId) {
|
|
87
|
+
return [...(credentials.get(userId)?.keys() ?? [])];
|
|
88
|
+
},
|
|
89
|
+
async lease(userId, server, previous, holder, ms) {
|
|
90
|
+
if (credentials.get(userId)?.get(server) !== previous) return false;
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const held = leases.get(key(userId, server));
|
|
93
|
+
if (held && held.until > now && held.holder !== holder) return false;
|
|
94
|
+
leases.set(key(userId, server), { holder, until: now + ms });
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
async getClient(server, issuer) {
|
|
98
|
+
return clients.get(key(server, issuer)) ?? null;
|
|
99
|
+
},
|
|
100
|
+
async putClient(server, issuer, value) {
|
|
101
|
+
clients.set(key(server, issuer), value);
|
|
102
|
+
},
|
|
103
|
+
async putFlow(state, value, expiresAt) {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
// the oldest first: past their time, or past the most kept
|
|
106
|
+
for (const [old, flow] of flows) {
|
|
107
|
+
if (flow.expiresAt > now && flows.size < MEMORY_FLOWS) break;
|
|
108
|
+
flows.delete(old);
|
|
109
|
+
}
|
|
110
|
+
flows.set(state, { value, expiresAt });
|
|
111
|
+
},
|
|
112
|
+
async takeFlow(state) {
|
|
113
|
+
const flow = flows.get(state);
|
|
114
|
+
flows.delete(state);
|
|
115
|
+
return flow && flow.expiresAt > Date.now() ? flow.value : null;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/transports.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
StreamableHTTPClientTransport,
|
|
3
3
|
type AuthProvider,
|
|
4
|
+
type FetchLike,
|
|
4
5
|
type StreamableHTTPClientTransportOptions,
|
|
5
6
|
type Transport,
|
|
6
7
|
} from "@modelcontextprotocol/client";
|
|
@@ -17,6 +18,10 @@ export interface HttpMade {
|
|
|
17
18
|
authProvider: boolean;
|
|
18
19
|
/** It sends an `Authorization` header of its own. */
|
|
19
20
|
authorization: boolean;
|
|
21
|
+
/** The server's URL: the protected resource an `oauth()` server's tokens are for. */
|
|
22
|
+
url: URL;
|
|
23
|
+
/** Its `fetch`, which an `oauth()` server's requests to the authorization server go through too. */
|
|
24
|
+
fetch: FetchLike | undefined;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/** The factories `http()` made: only these can carry each user's credential. */
|
|
@@ -41,6 +46,8 @@ export function http(url: string | URL, options: HttpOptions = {}): (authProvide
|
|
|
41
46
|
madeByHttp.set(make, {
|
|
42
47
|
authProvider: rest.authProvider !== undefined,
|
|
43
48
|
authorization: new Headers(rest.requestInit?.headers).has("authorization"),
|
|
49
|
+
url: target,
|
|
50
|
+
fetch: rest.fetch,
|
|
44
51
|
});
|
|
45
52
|
return make;
|
|
46
53
|
}
|