@ultimatelemon-eu/mcp-http 2.1.0 → 2.3.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 CHANGED
@@ -43,6 +43,7 @@ process.once('SIGTERM', () => {
43
43
  | `basePath` | `''` | Also serve MCP on `<basePath>` and `<basePath>/mcp`, and `<basePath>/healthz` |
44
44
  | `health` | always ok | Return `false` for a 503 on `/healthz` |
45
45
  | `apiKey` | off | Require the caller's key; `hint` is added to the 401 message |
46
+ | `oauth` | off | Let callers sign in with the upstream service; see [OAuth](#oauth) |
46
47
  | `logger` | silent | Anything with `error(obj, msg)`, such as pino |
47
48
 
48
49
  It returns `{ url, port, close() }`.
@@ -53,12 +54,71 @@ With `apiKey` set, every request to `/mcp` must carry the caller's own key for
53
54
  the upstream API, in `X-Api-Key` or as `Authorization: Bearer <key>`
54
55
  (`X-Api-Key` wins when both are present). Without one the server answers 401
55
56
  before building an MCP server. The key reaches `createMcpServer` as
56
- `apiKey`; it is never logged or stored.
57
+ `apiKey`; it is never logged or stored. Other per-caller settings can travel
58
+ as headers of their own, read from `headers` in the same context.
57
59
 
58
60
  This suits public servers where each user brings their own credentials: no
59
- OAuth, no sessions, no database. In claude.ai, add the connector with
61
+ sessions, no database. In claude.ai, add the connector with
60
62
  "No sign-in" and the key as a request header.
61
63
 
64
+ ## OAuth
65
+
66
+ With `oauth` set, the server is also an OAuth 2.1 authorization server in front
67
+ of an upstream provider, so a client such as claude.ai can connect with a
68
+ sign-in instead of a pasted key. It needs `ENCRYPTION_KEY` (32 bytes, base64)
69
+ and keeps no state: client registrations, codes and tokens are sealed with that
70
+ key, and the upstream token travels inside the access token.
71
+
72
+ ```ts
73
+ import { oauth2Upstream, startMcpHttpServer } from '@ultimatelemon-eu/mcp-http';
74
+
75
+ await startMcpHttpServer({
76
+ createMcpServer: ({ apiKey, oauth }) => buildServer(apiKey, oauth?.settings),
77
+ port: 3000,
78
+ basePath: '/example',
79
+ apiKey: {},
80
+ oauth: {
81
+ publicUrl: 'https://mcp.example.com',
82
+ name: 'Example',
83
+ upstream: oauth2Upstream({
84
+ authorizeUrl: 'https://example.com/oauth/authorize',
85
+ tokenUrl: 'https://example.com/oauth/token',
86
+ clientId: process.env.EXAMPLE_CLIENT_ID ?? '',
87
+ clientSecret: process.env.EXAMPLE_CLIENT_SECRET ?? '',
88
+ scopes: ['read']
89
+ }),
90
+ settings: async (accessToken) => ({
91
+ title: 'Choose an account',
92
+ fields: [
93
+ {
94
+ name: 'account',
95
+ label: 'Account',
96
+ options: await accountsOf(accessToken)
97
+ }
98
+ ]
99
+ })
100
+ }
101
+ });
102
+ ```
103
+
104
+ - Register `<publicUrl><basePath>/oauth/callback` as the redirect URI at the
105
+ upstream provider.
106
+ - Clients register dynamically (RFC 7591) and must use PKCE (S256).
107
+ - `settings` is optional: a form shown once after the upstream sign-in, with
108
+ radio or checkbox fields. Submitted values are checked against the options,
109
+ and the answers reach `createMcpServer` as `oauth.settings` on every request.
110
+ - In `createMcpServer`, `apiKey` holds the upstream access token for both
111
+ callers who signed in and callers who sent a key.
112
+ - Metadata is served on `<basePath>/.well-known/...` and on
113
+ `/.well-known/oauth-authorization-server<basePath>` (and the protected
114
+ resource equivalent). Behind a path-routing proxy, route those root paths to
115
+ the server too.
116
+ - Access tokens last an hour, or less when the upstream token expires sooner;
117
+ refresh tokens last 90 days and renew the upstream token when needed.
118
+ - Nothing can be revoked server-side: a user revokes the app at the upstream
119
+ provider, and rotating `ENCRYPTION_KEY` signs everyone out. A code can be
120
+ redeemed once per process.
121
+
62
122
  ## Why stateless
63
123
 
64
124
  A new server per request means no session table, so it does not matter which
package/dist/index.d.ts CHANGED
@@ -1 +1,4 @@
1
1
  export { startMcpHttpServer, type ApiKeyOptions, type Logger, type McpHttpOptions, type McpHttpServer, type RequestContext } from './server.js';
2
+ export { type OAuthGrant, type OAuthHandler, type OAuthOptions } from './oauth.js';
3
+ export { type SettingsField, type SettingsForm, type SettingsOption } from './pages.js';
4
+ export { oauth2Upstream, UpstreamError, type OAuth2UpstreamOptions, type OAuthUpstream, type UpstreamTokens } from './upstream.js';
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { startMcpHttpServer } from './server.js';
2
+ export { oauth2Upstream, UpstreamError } from './upstream.js';
@@ -0,0 +1,25 @@
1
+ import { type SettingsForm } from './pages.js';
2
+ import type { OAuthUpstream } from './upstream.js';
3
+ export interface OAuthOptions {
4
+ /** Public origin, e.g. `https://mcp.example.com`. The issuer is this plus the base path. */
5
+ publicUrl: string;
6
+ /** Upstream service name for the pages, e.g. `Moneybird`. */
7
+ name: string;
8
+ upstream: OAuthUpstream;
9
+ /** Asked once after the upstream sign-in; the answers travel inside the issued tokens. */
10
+ settings?: (accessToken: string) => Promise<SettingsForm | undefined>;
11
+ }
12
+ export interface OAuthGrant {
13
+ /** The upstream access token. */
14
+ accessToken: string;
15
+ settings: Record<string, string>;
16
+ }
17
+ export interface OAuthHandler {
18
+ owns(path: string): boolean;
19
+ handle(request: Request): Promise<Response>;
20
+ /** Opens an access token this server issued; undefined when it is not one, or expired. */
21
+ authenticate(token: string): OAuthGrant | undefined;
22
+ /** `WWW-Authenticate` value that points a client at the metadata. */
23
+ challenge(error?: string): string;
24
+ }
25
+ export declare function createOAuthHandler(options: OAuthOptions, basePath: string): OAuthHandler;
package/dist/oauth.js ADDED
@@ -0,0 +1,446 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { decrypt, encrypt, isEncrypted } from '@ultimatelemon-eu/crypto';
3
+ import { errorPage, htmlResponse, settingsPage } from './pages.js';
4
+ const STATE_TTL_MS = 10 * 60 * 1000;
5
+ const CODE_TTL_MS = 60 * 1000;
6
+ const ACCESS_TTL_MS = 60 * 60 * 1000;
7
+ const REFRESH_TTL_MS = 90 * 24 * 60 * 60 * 1000;
8
+ const UPSTREAM_LEEWAY_MS = 5 * 60 * 1000;
9
+ const COOKIE = 'mcp_oauth_state';
10
+ function sealer(issuer) {
11
+ return {
12
+ seal(value, ttlMs) {
13
+ const sealed = {
14
+ ...value,
15
+ i: issuer,
16
+ ...(ttlMs === undefined ? {} : { x: Date.now() + ttlMs })
17
+ };
18
+ return encrypt(JSON.stringify(sealed));
19
+ },
20
+ open(value, type) {
21
+ if (!value || !isEncrypted(value))
22
+ return undefined;
23
+ try {
24
+ const parsed = JSON.parse(decrypt(value));
25
+ if (parsed.t !== type || parsed.i !== issuer)
26
+ return undefined;
27
+ if (parsed.x !== undefined && parsed.x < Date.now())
28
+ return undefined;
29
+ return parsed;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ };
36
+ }
37
+ function json(body, status = 200) {
38
+ return new Response(JSON.stringify(body), {
39
+ status,
40
+ headers: {
41
+ 'content-type': 'application/json',
42
+ 'cache-control': 'no-store',
43
+ 'access-control-allow-origin': '*'
44
+ }
45
+ });
46
+ }
47
+ function oauthError(error, description, status = 400) {
48
+ return json({ error, error_description: description }, status);
49
+ }
50
+ function redirect(location, cookie) {
51
+ const headers = new Headers({ location, 'cache-control': 'no-store' });
52
+ if (cookie)
53
+ headers.append('set-cookie', cookie);
54
+ return new Response(null, { status: 302, headers });
55
+ }
56
+ function readCookie(request, name) {
57
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
58
+ const [key, ...rest] = part.trim().split('=');
59
+ if (key === name)
60
+ return rest.join('=');
61
+ }
62
+ return undefined;
63
+ }
64
+ const LOOPBACK = ['127.0.0.1', '[::1]', 'localhost'];
65
+ function isAllowedRedirectUri(value) {
66
+ try {
67
+ const url = new URL(value);
68
+ if (url.hash)
69
+ return false;
70
+ if (url.protocol === 'https:')
71
+ return true;
72
+ return url.protocol === 'http:' && LOOPBACK.includes(url.hostname);
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ // Exact match, except that a loopback http uri may use any port (RFC 8252 7.3).
79
+ function redirectUriMatches(registered, presented) {
80
+ if (registered === presented)
81
+ return true;
82
+ try {
83
+ const left = new URL(registered);
84
+ const right = new URL(presented);
85
+ return (left.protocol === 'http:' &&
86
+ right.protocol === 'http:' &&
87
+ LOOPBACK.includes(left.hostname) &&
88
+ left.hostname === right.hostname &&
89
+ left.pathname === right.pathname &&
90
+ left.search === right.search);
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ function verifyPkce(verifier, challenge) {
97
+ const computed = Buffer.from(createHash('sha256').update(verifier).digest('base64url'));
98
+ const expected = Buffer.from(challenge);
99
+ return (computed.length === expected.length && timingSafeEqual(computed, expected));
100
+ }
101
+ function sha256(value) {
102
+ return createHash('sha256').update(value).digest('hex');
103
+ }
104
+ async function readForm(request) {
105
+ return new URLSearchParams(await request.text());
106
+ }
107
+ /** Validates submitted settings against the form; returns an error message instead when invalid. */
108
+ function readSettings(form, body) {
109
+ const settings = {};
110
+ for (const field of form.fields) {
111
+ const allowed = new Set(field.options.map((option) => option.value));
112
+ const values = body.getAll(field.name);
113
+ if (values.some((value) => !allowed.has(value))) {
114
+ return `"${field.label}" has a value that is not on the list.`;
115
+ }
116
+ if (!field.multiple && values.length !== 1) {
117
+ return `Choose one option for "${field.label}".`;
118
+ }
119
+ settings[field.name] = values.join(',');
120
+ }
121
+ return settings;
122
+ }
123
+ export function createOAuthHandler(options, basePath) {
124
+ // Fails at startup rather than at the first sign-in when ENCRYPTION_KEY is missing or malformed.
125
+ decrypt(encrypt('check'));
126
+ const origin = options.publicUrl.replace(/\/+$/, '');
127
+ const issuer = `${origin}${basePath}`;
128
+ const { seal, open } = sealer(issuer);
129
+ const resourceMetadataUrl = `${issuer}/.well-known/oauth-protected-resource`;
130
+ const callbackUri = `${issuer}/oauth/callback`;
131
+ const paths = {
132
+ register: `${basePath}/register`,
133
+ authorize: `${basePath}/authorize`,
134
+ token: `${basePath}/token`,
135
+ callback: `${basePath}/oauth/callback`,
136
+ settings: `${basePath}/oauth/settings`
137
+ };
138
+ const resourcePaths = [
139
+ `${basePath}/.well-known/oauth-protected-resource`,
140
+ `/.well-known/oauth-protected-resource${basePath}`
141
+ ];
142
+ const serverPaths = new Set([
143
+ `${basePath}/.well-known/oauth-authorization-server`,
144
+ `${basePath}/.well-known/openid-configuration`,
145
+ `/.well-known/oauth-authorization-server${basePath}`,
146
+ `/.well-known/openid-configuration${basePath}`
147
+ ]);
148
+ const endpoints = new Set(Object.values(paths));
149
+ // Codes are sealed, not stored, so this is what keeps one from being redeemed twice.
150
+ const redeemed = new Map();
151
+ function remember(code) {
152
+ const now = Date.now();
153
+ for (const [key, expires] of redeemed) {
154
+ if (expires < now)
155
+ redeemed.delete(key);
156
+ }
157
+ const key = sha256(code);
158
+ if (redeemed.has(key))
159
+ return false;
160
+ redeemed.set(key, now + CODE_TTL_MS);
161
+ return true;
162
+ }
163
+ function isResourcePath(path) {
164
+ return resourcePaths.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
165
+ }
166
+ function owns(path) {
167
+ return isResourcePath(path) || serverPaths.has(path) || endpoints.has(path);
168
+ }
169
+ function authorizationServerMetadata() {
170
+ return {
171
+ issuer,
172
+ authorization_endpoint: `${origin}${paths.authorize}`,
173
+ token_endpoint: `${origin}${paths.token}`,
174
+ registration_endpoint: `${origin}${paths.register}`,
175
+ response_types_supported: ['code'],
176
+ response_modes_supported: ['query'],
177
+ grant_types_supported: ['authorization_code', 'refresh_token'],
178
+ token_endpoint_auth_methods_supported: ['none'],
179
+ code_challenge_methods_supported: ['S256']
180
+ };
181
+ }
182
+ async function register(request) {
183
+ let body;
184
+ try {
185
+ body = (await request.json());
186
+ }
187
+ catch {
188
+ return oauthError('invalid_client_metadata', 'Body must be JSON.');
189
+ }
190
+ const uris = body.redirect_uris;
191
+ if (!Array.isArray(uris) ||
192
+ uris.length === 0 ||
193
+ !uris.every((uri) => typeof uri === 'string' && isAllowedRedirectUri(uri))) {
194
+ return oauthError('invalid_redirect_uri', 'redirect_uris must be https uris or loopback http uris.');
195
+ }
196
+ return json({
197
+ client_id: seal({ t: 'client', r: uris }),
198
+ client_id_issued_at: Math.floor(Date.now() / 1000),
199
+ redirect_uris: uris,
200
+ grant_types: ['authorization_code', 'refresh_token'],
201
+ response_types: ['code'],
202
+ token_endpoint_auth_method: 'none',
203
+ ...(typeof body.client_name === 'string'
204
+ ? { client_name: body.client_name }
205
+ : {})
206
+ }, 201);
207
+ }
208
+ function redirectWithError(flow, error, description) {
209
+ const url = new URL(flow.r);
210
+ url.searchParams.set('error', error);
211
+ url.searchParams.set('error_description', description);
212
+ if (flow.s !== undefined)
213
+ url.searchParams.set('state', flow.s);
214
+ return redirect(url.toString());
215
+ }
216
+ // Until the client and redirect uri check out, errors render here: redirecting
217
+ // to an unverified uri would make this an open redirector.
218
+ function authorize(url) {
219
+ const params = url.searchParams;
220
+ const clientId = params.get('client_id');
221
+ const redirectUri = params.get('redirect_uri');
222
+ const client = open(clientId, 'client');
223
+ if (!clientId || !client) {
224
+ return htmlResponse(errorPage('Unknown client', 'This client is not registered here. Remove the connector and add it again.'), 400);
225
+ }
226
+ if (!redirectUri ||
227
+ !client.r.some((registered) => redirectUriMatches(registered, redirectUri))) {
228
+ return htmlResponse(errorPage('Invalid redirect uri', 'The client did not register this redirect uri.'), 400);
229
+ }
230
+ const state = params.get('state') ?? undefined;
231
+ const flow = {
232
+ r: redirectUri,
233
+ ...(state !== undefined ? { s: state } : {})
234
+ };
235
+ if (params.get('response_type') !== 'code') {
236
+ return redirectWithError(flow, 'unsupported_response_type', 'Only the authorization code flow is supported.');
237
+ }
238
+ const challenge = params.get('code_challenge');
239
+ if (!challenge || params.get('code_challenge_method') !== 'S256') {
240
+ return redirectWithError(flow, 'invalid_request', 'PKCE with code_challenge_method=S256 is required.');
241
+ }
242
+ const sealed = seal({ t: 'state', c: clientId, p: challenge, ...flow }, STATE_TTL_MS);
243
+ // The cookie covers an upstream that does not echo `state` back.
244
+ return redirect(options.upstream.authorizeUrl({
245
+ redirectUri: callbackUri,
246
+ state: sealed
247
+ }), `${COOKIE}=${sealed}; Path=${basePath}/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=${String(STATE_TTL_MS / 1000)}`);
248
+ }
249
+ function finish(flow, tokens, settings) {
250
+ const code = seal({
251
+ t: 'code',
252
+ c: flow.c,
253
+ r: flow.r,
254
+ p: flow.p,
255
+ u: tokens,
256
+ a: settings,
257
+ ...(flow.s !== undefined ? { s: flow.s } : {})
258
+ }, CODE_TTL_MS);
259
+ const target = new URL(flow.r);
260
+ target.searchParams.set('code', code);
261
+ if (flow.s !== undefined)
262
+ target.searchParams.set('state', flow.s);
263
+ return redirect(target.toString(), `${COOKIE}=; Path=${basePath}/oauth; HttpOnly; Secure; Max-Age=0`);
264
+ }
265
+ async function askSettings(flow, tokens) {
266
+ const form = await options.settings?.(tokens.accessToken);
267
+ if (!form || form.fields.length === 0)
268
+ return finish(flow, tokens, {});
269
+ const pending = seal({
270
+ t: 'pending',
271
+ c: flow.c,
272
+ r: flow.r,
273
+ p: flow.p,
274
+ u: tokens,
275
+ ...(flow.s !== undefined ? { s: flow.s } : {})
276
+ }, STATE_TTL_MS);
277
+ return htmlResponse(settingsPage(form, `${origin}${paths.settings}`, pending), 200);
278
+ }
279
+ async function callback(request, url) {
280
+ const failure = url.searchParams.get('error');
281
+ if (failure) {
282
+ return htmlResponse(errorPage(`${options.name} refused the sign-in`, url.searchParams.get('error_description') ?? failure), 400);
283
+ }
284
+ const state = open(url.searchParams.get('state'), 'state') ??
285
+ open(readCookie(request, COOKIE), 'state');
286
+ const code = url.searchParams.get('code');
287
+ if (!state || !code) {
288
+ return htmlResponse(errorPage('Sign-in expired', 'This sign-in is unknown or took too long. Start again from your client.'), 400);
289
+ }
290
+ try {
291
+ const tokens = await options.upstream.exchangeCode({
292
+ code,
293
+ redirectUri: callbackUri
294
+ });
295
+ return await askSettings(state, tokens);
296
+ }
297
+ catch (error) {
298
+ return htmlResponse(errorPage(`Could not finish signing in to ${options.name}`, error instanceof Error ? error.message : String(error)), 502);
299
+ }
300
+ }
301
+ async function settings(request) {
302
+ const body = await readForm(request);
303
+ const pending = open(body.get('pending'), 'pending');
304
+ if (!pending) {
305
+ return htmlResponse(errorPage('Sign-in expired', 'This took too long. Start again from your client.'), 400);
306
+ }
307
+ let form;
308
+ try {
309
+ // Asked again rather than trusted from the page, so only offered values pass.
310
+ form = await options.settings?.(pending.u.accessToken);
311
+ }
312
+ catch (error) {
313
+ return htmlResponse(errorPage(`Could not reach ${options.name}`, error instanceof Error ? error.message : String(error)), 502);
314
+ }
315
+ const answers = form ? readSettings(form, body) : {};
316
+ if (typeof answers === 'string') {
317
+ return htmlResponse(errorPage('Invalid choice', answers), 400);
318
+ }
319
+ return finish(pending, pending.u, answers);
320
+ }
321
+ function issue(clientId, tokens, settings) {
322
+ // Expire before the upstream token does, so the client refreshes both in time.
323
+ const ttl = tokens.expiresAt === undefined
324
+ ? ACCESS_TTL_MS
325
+ : Math.max(60_000, Math.min(ACCESS_TTL_MS, tokens.expiresAt - Date.now() - UPSTREAM_LEEWAY_MS));
326
+ return json({
327
+ access_token: seal({ t: 'access', u: tokens.accessToken, a: settings }, ttl),
328
+ token_type: 'Bearer',
329
+ expires_in: Math.floor(ttl / 1000),
330
+ refresh_token: seal({ t: 'refresh', c: clientId, u: tokens, a: settings }, REFRESH_TTL_MS)
331
+ });
332
+ }
333
+ function exchangeCode(body) {
334
+ const raw = body.get('code');
335
+ const verifier = body.get('code_verifier');
336
+ const code = open(raw, 'code');
337
+ if (!raw || !code || !verifier) {
338
+ return oauthError('invalid_grant', 'The code is unknown or expired, or code_verifier is missing.');
339
+ }
340
+ const clientId = body.get('client_id');
341
+ if (clientId !== null && clientId !== code.c) {
342
+ return oauthError('invalid_grant', 'This code belongs to another client.');
343
+ }
344
+ const redirectUri = body.get('redirect_uri');
345
+ if (redirectUri !== null && redirectUri !== code.r) {
346
+ return oauthError('invalid_grant', 'redirect_uri does not match.');
347
+ }
348
+ if (!verifyPkce(verifier, code.p)) {
349
+ return oauthError('invalid_grant', 'The PKCE verifier does not match.');
350
+ }
351
+ if (!remember(raw)) {
352
+ return oauthError('invalid_grant', 'This code was already used.');
353
+ }
354
+ return issue(code.c, code.u, code.a);
355
+ }
356
+ async function exchangeRefresh(body) {
357
+ const refresh = open(body.get('refresh_token'), 'refresh');
358
+ if (!refresh) {
359
+ return oauthError('invalid_grant', 'The refresh token is unknown or expired.');
360
+ }
361
+ const clientId = body.get('client_id');
362
+ if (clientId !== null && clientId !== refresh.c) {
363
+ return oauthError('invalid_grant', 'This refresh token belongs to another client.');
364
+ }
365
+ let tokens = refresh.u;
366
+ if (tokens.expiresAt !== undefined &&
367
+ tokens.expiresAt - UPSTREAM_LEEWAY_MS <= Date.now()) {
368
+ if (!tokens.refreshToken || !options.upstream.refresh) {
369
+ return oauthError('invalid_grant', `The ${options.name} sign-in expired. Sign in again.`);
370
+ }
371
+ try {
372
+ const renewed = await options.upstream.refresh(tokens.refreshToken);
373
+ tokens = {
374
+ ...renewed,
375
+ refreshToken: renewed.refreshToken ?? tokens.refreshToken
376
+ };
377
+ }
378
+ catch (error) {
379
+ return oauthError('invalid_grant', error instanceof Error ? error.message : String(error));
380
+ }
381
+ }
382
+ return issue(refresh.c, tokens, refresh.a);
383
+ }
384
+ async function token(request) {
385
+ const body = await readForm(request);
386
+ const grantType = body.get('grant_type');
387
+ if (grantType === 'authorization_code')
388
+ return exchangeCode(body);
389
+ if (grantType === 'refresh_token')
390
+ return exchangeRefresh(body);
391
+ return oauthError('unsupported_grant_type', 'Use authorization_code or refresh_token.');
392
+ }
393
+ async function handle(request) {
394
+ const url = new URL(request.url);
395
+ const path = url.pathname;
396
+ const method = request.method;
397
+ if (method === 'OPTIONS') {
398
+ return new Response(null, {
399
+ status: 204,
400
+ headers: {
401
+ 'access-control-allow-origin': '*',
402
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
403
+ 'access-control-allow-headers': 'authorization, content-type, mcp-protocol-version',
404
+ 'access-control-max-age': '86400'
405
+ }
406
+ });
407
+ }
408
+ if (method === 'GET' && isResourcePath(path)) {
409
+ return json({
410
+ resource: issuer,
411
+ authorization_servers: [issuer],
412
+ bearer_methods_supported: ['header'],
413
+ resource_name: options.name
414
+ });
415
+ }
416
+ if (method === 'GET' && serverPaths.has(path)) {
417
+ return json(authorizationServerMetadata());
418
+ }
419
+ if (method === 'POST' && path === paths.register)
420
+ return register(request);
421
+ if (method === 'GET' && path === paths.authorize)
422
+ return authorize(url);
423
+ if (method === 'GET' && path === paths.callback) {
424
+ return callback(request, url);
425
+ }
426
+ if (method === 'POST' && path === paths.settings)
427
+ return settings(request);
428
+ if (method === 'POST' && path === paths.token)
429
+ return token(request);
430
+ return json({ error: 'Method not allowed' }, 405);
431
+ }
432
+ return {
433
+ owns,
434
+ handle,
435
+ authenticate(token) {
436
+ const access = open(token, 'access');
437
+ return access ? { accessToken: access.u, settings: access.a } : undefined;
438
+ },
439
+ challenge(error) {
440
+ const parts = [`resource_metadata="${resourceMetadataUrl}"`];
441
+ if (error)
442
+ parts.unshift(`error="${error}"`);
443
+ return `Bearer ${parts.join(', ')}`;
444
+ }
445
+ };
446
+ }
@@ -0,0 +1,24 @@
1
+ export interface SettingsOption {
2
+ value: string;
3
+ label: string;
4
+ hint?: string;
5
+ }
6
+ export interface SettingsField {
7
+ name: string;
8
+ label: string;
9
+ hint?: string;
10
+ options: SettingsOption[];
11
+ /** Checkboxes instead of radio buttons; the answer is the checked values joined by commas. */
12
+ multiple?: boolean;
13
+ /** Values checked when the page opens. */
14
+ defaults?: string[];
15
+ }
16
+ export interface SettingsForm {
17
+ title: string;
18
+ intro?: string;
19
+ fields: SettingsField[];
20
+ }
21
+ export declare function escapeHtml(value: string): string;
22
+ export declare function htmlResponse(body: string, status: number): Response;
23
+ export declare function errorPage(title: string, detail: string): string;
24
+ export declare function settingsPage(form: SettingsForm, action: string, pending: string): string;
package/dist/pages.js ADDED
@@ -0,0 +1,85 @@
1
+ const STYLE = `
2
+ :root { color-scheme: light dark; }
3
+ body {
4
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
5
+ font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
6
+ background: #fbfbfa; color: #1a1a18;
7
+ }
8
+ main { width: min(30rem, calc(100vw - 2rem)); padding: 2rem 0; }
9
+ h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
10
+ p { margin: 0 0 1.5rem; color: #5c5c56; }
11
+ fieldset { border: 0; margin: 0 0 1.5rem; padding: 0; display: grid; gap: .5rem; }
12
+ legend { font-weight: 600; margin-bottom: .25rem; }
13
+ legend small { display: block; font-weight: 400; color: #5c5c56; }
14
+ label {
15
+ display: flex; gap: .75rem; align-items: baseline; padding: .75rem 1rem;
16
+ border: 1px solid #dcdcd6; border-radius: .5rem; background: #fff; cursor: pointer;
17
+ }
18
+ label:has(input:checked) { border-color: #1a1a18; }
19
+ .hint { display: block; color: #5c5c56; font-size: .875rem; }
20
+ button {
21
+ width: 100%; padding: .875rem 1rem; font: inherit; font-weight: 600; cursor: pointer;
22
+ background: #1a1a18; color: #fbfbfa; border: 0; border-radius: .5rem;
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ body { background: #171715; color: #f2f2ef; }
26
+ p, .hint, legend small { color: #a3a39c; }
27
+ label { background: #201f1d; border-color: #35342f; }
28
+ label:has(input:checked) { border-color: #f2f2ef; }
29
+ button { background: #f2f2ef; color: #171715; }
30
+ }
31
+ `;
32
+ export function escapeHtml(value) {
33
+ return value
34
+ .replace(/&/g, '&amp;')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/"/g, '&quot;');
38
+ }
39
+ function page(title, body) {
40
+ return `<!doctype html>
41
+ <html lang="en">
42
+ <head>
43
+ <meta charset="utf-8">
44
+ <meta name="viewport" content="width=device-width, initial-scale=1">
45
+ <title>${escapeHtml(title)}</title>
46
+ <style>${STYLE}</style>
47
+ </head>
48
+ <body><main>${body}</main></body>
49
+ </html>`;
50
+ }
51
+ export function htmlResponse(body, status) {
52
+ return new Response(body, {
53
+ status,
54
+ headers: {
55
+ 'content-type': 'text/html; charset=utf-8',
56
+ 'cache-control': 'no-store',
57
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
58
+ 'x-frame-options': 'DENY'
59
+ }
60
+ });
61
+ }
62
+ export function errorPage(title, detail) {
63
+ return page(title, `<h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p>`);
64
+ }
65
+ function fieldHtml(field) {
66
+ const type = field.multiple ? 'checkbox' : 'radio';
67
+ const checked = new Set(field.defaults ?? (field.multiple ? [] : [field.options[0]?.value ?? '']));
68
+ const options = field.options
69
+ .map((option) => `<label>
70
+ <input type="${type}" name="${escapeHtml(field.name)}" value="${escapeHtml(option.value)}"${checked.has(option.value) ? ' checked' : ''}>
71
+ <span>${escapeHtml(option.label)}${option.hint ? `<span class="hint">${escapeHtml(option.hint)}</span>` : ''}</span>
72
+ </label>`)
73
+ .join('\n');
74
+ const hint = field.hint ? `<small>${escapeHtml(field.hint)}</small>` : '';
75
+ return `<fieldset><legend>${escapeHtml(field.label)}${hint}</legend>${options}</fieldset>`;
76
+ }
77
+ export function settingsPage(form, action, pending) {
78
+ const intro = form.intro ? `<p>${escapeHtml(form.intro)}</p>` : '';
79
+ return page(form.title, `<h1>${escapeHtml(form.title)}</h1>${intro}
80
+ <form method="post" action="${escapeHtml(action)}">
81
+ <input type="hidden" name="pending" value="${escapeHtml(pending)}">
82
+ ${form.fields.map(fieldHtml).join('\n')}
83
+ <button type="submit">Connect</button>
84
+ </form>`);
85
+ }
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type McpServer } from '@modelcontextprotocol/server';
2
+ import { type OAuthOptions } from './oauth.js';
2
3
  /** The slice of a pino-style logger this package calls. */
3
4
  export interface Logger {
4
5
  error(obj: object, msg?: string): void;
@@ -6,6 +7,12 @@ export interface Logger {
6
7
  export interface RequestContext {
7
8
  /** The caller's own key for the upstream API. Set when `apiKey` is configured. */
8
9
  apiKey?: string;
10
+ /** Headers of the HTTP request, for per-caller settings beyond the key. */
11
+ headers: Headers;
12
+ /** Set when the caller signed in through `oauth`; `apiKey` then holds the upstream token. */
13
+ oauth?: {
14
+ settings: Record<string, string>;
15
+ };
9
16
  }
10
17
  export interface ApiKeyOptions {
11
18
  /** Added to the 401 message, e.g. where to get a key. */
@@ -23,6 +30,8 @@ export interface McpHttpOptions {
23
30
  health?: () => boolean | Promise<boolean>;
24
31
  /** Require every MCP request to carry the caller's key in `X-Api-Key` or `Authorization: Bearer`. */
25
32
  apiKey?: ApiKeyOptions;
33
+ /** Sign in with the upstream service instead of pasting a key. Needs `ENCRYPTION_KEY`. */
34
+ oauth?: OAuthOptions;
26
35
  logger?: Logger;
27
36
  }
28
37
  export interface McpHttpServer {
package/dist/server.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { toNodeHandler } from '@modelcontextprotocol/node';
3
3
  import { createMcpHandler } from '@modelcontextprotocol/server';
4
+ import { isEncrypted } from '@ultimatelemon-eu/crypto';
5
+ import { createOAuthHandler } from './oauth.js';
4
6
  const silent = { error: () => { } };
5
7
  // PKG-10: Node's `Server.close()` waits for every still-open connection to go
6
8
  // idle on its own; a keep-alive client that goes idle (or stays busy) *after*
@@ -35,14 +37,51 @@ function jsonRpcError(res, status, message) {
35
37
  id: null
36
38
  });
37
39
  }
38
- function apiKeyFrom(req) {
40
+ function headerKey(req) {
39
41
  const header = req.headers['x-api-key'];
40
- const direct = (Array.isArray(header) ? header[0] : header)?.trim();
41
- if (direct)
42
- return direct;
42
+ return (Array.isArray(header) ? header[0] : header)?.trim() || undefined;
43
+ }
44
+ function bearerFrom(req) {
43
45
  const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '');
44
46
  return bearer?.[1]?.trim() || undefined;
45
47
  }
48
+ const MAX_OAUTH_BODY_BYTES = 64 * 1024;
49
+ async function toRequest(req) {
50
+ const chunks = [];
51
+ let size = 0;
52
+ for await (const chunk of req) {
53
+ size += chunk.length;
54
+ if (size > MAX_OAUTH_BODY_BYTES)
55
+ throw new Error('Request body too large');
56
+ chunks.push(chunk);
57
+ }
58
+ const headers = new Headers();
59
+ for (const [name, value] of Object.entries(req.headers)) {
60
+ if (value !== undefined) {
61
+ headers.set(name, Array.isArray(value) ? value.join(', ') : value);
62
+ }
63
+ }
64
+ const method = req.method ?? 'GET';
65
+ return new Request(new URL(req.url ?? '/', 'http://localhost'), {
66
+ method,
67
+ headers,
68
+ ...(method === 'GET' || method === 'HEAD'
69
+ ? {}
70
+ : { body: Buffer.concat(chunks) })
71
+ });
72
+ }
73
+ async function sendResponse(res, response) {
74
+ const headers = {};
75
+ response.headers.forEach((value, name) => {
76
+ if (name !== 'set-cookie')
77
+ headers[name] = value;
78
+ });
79
+ const cookies = response.headers.getSetCookie();
80
+ if (cookies.length > 0)
81
+ headers['set-cookie'] = cookies;
82
+ res.writeHead(response.status, headers);
83
+ res.end(Buffer.from(await response.arrayBuffer()));
84
+ }
46
85
  // PKG-10: closes `http`, letting in-flight requests finish normally. A
47
86
  // one-off `closeIdleConnections()` call only catches connections that are
48
87
  // already idle at the exact moment close() runs; any connection that is
@@ -80,9 +119,22 @@ export async function startMcpHttpServer(options) {
80
119
  if (base)
81
120
  mcpPaths.add(base);
82
121
  const healthPaths = new Set([`${base}/healthz`, '/healthz']);
122
+ const oauth = options.oauth
123
+ ? createOAuthHandler(options.oauth, base)
124
+ : undefined;
83
125
  // The key travels as pass-through authInfo, the SDK's channel from the
84
126
  // HTTP request to the per-request factory.
85
- const mcpHandler = createMcpHandler(({ authInfo }) => options.createMcpServer(authInfo ? { apiKey: authInfo.token } : {}), {
127
+ const mcpHandler = createMcpHandler(({ authInfo, requestInfo }) => options.createMcpServer({
128
+ ...(authInfo ? { apiKey: authInfo.token } : {}),
129
+ ...(authInfo?.extra?.['settings']
130
+ ? {
131
+ oauth: {
132
+ settings: authInfo.extra['settings']
133
+ }
134
+ }
135
+ : {}),
136
+ headers: requestInfo?.headers ?? new Headers()
137
+ }), {
86
138
  onerror: (error) => {
87
139
  logger.error({ err: String(error) }, 'mcp request failed');
88
140
  }
@@ -99,15 +151,36 @@ export async function startMcpHttpServer(options) {
99
151
  jsonRpcError(res, 405, 'Method not allowed');
100
152
  return;
101
153
  }
102
- if (!options.apiKey) {
154
+ if (!options.apiKey && !oauth) {
103
155
  await nodeHandler(req, res);
104
156
  return;
105
157
  }
106
- const apiKey = apiKeyFrom(req);
158
+ const bearer = bearerFrom(req);
159
+ // Tokens this server issued are sealed; an upstream key never looks like one.
160
+ if (oauth && bearer && isEncrypted(bearer) && !headerKey(req)) {
161
+ const grant = oauth.authenticate(bearer);
162
+ if (!grant) {
163
+ res.setHeader('www-authenticate', oauth.challenge('invalid_token'));
164
+ jsonRpcError(res, 401, 'The access token is invalid or expired.');
165
+ return;
166
+ }
167
+ await nodeHandler(Object.assign(req, {
168
+ auth: {
169
+ token: grant.accessToken,
170
+ clientId: 'oauth',
171
+ scopes: [],
172
+ extra: { settings: grant.settings }
173
+ }
174
+ }), res);
175
+ return;
176
+ }
177
+ const apiKey = options.apiKey ? (headerKey(req) ?? bearer) : undefined;
107
178
  if (!apiKey) {
108
- const hint = options.apiKey.hint ? ` ${options.apiKey.hint}` : '';
109
- res.setHeader('www-authenticate', 'Bearer');
110
- jsonRpcError(res, 401, `Missing API key: send it in the X-Api-Key header or as Authorization: Bearer <key>.${hint}`);
179
+ const hint = options.apiKey?.hint ? ` ${options.apiKey.hint}` : '';
180
+ res.setHeader('www-authenticate', oauth ? oauth.challenge() : 'Bearer');
181
+ jsonRpcError(res, 401, options.apiKey
182
+ ? `Missing API key: send it in the X-Api-Key header or as Authorization: Bearer <key>.${hint}`
183
+ : 'Sign in to use this server.');
111
184
  return;
112
185
  }
113
186
  await nodeHandler(Object.assign(req, {
@@ -125,6 +198,10 @@ export async function startMcpHttpServer(options) {
125
198
  await handleMcp(req, res);
126
199
  return;
127
200
  }
201
+ if (oauth?.owns(path)) {
202
+ await sendResponse(res, await oauth.handle(await toRequest(req)));
203
+ return;
204
+ }
128
205
  sendJson(res, 404, { error: 'Not found' });
129
206
  }
130
207
  const http = createServer((req, res) => {
@@ -0,0 +1,31 @@
1
+ export interface UpstreamTokens {
2
+ accessToken: string;
3
+ refreshToken?: string;
4
+ /** Epoch milliseconds; unset when the upstream token does not expire. */
5
+ expiresAt?: number;
6
+ }
7
+ /** The OAuth provider behind the server, to which this server is a confidential client. */
8
+ export interface OAuthUpstream {
9
+ authorizeUrl(params: {
10
+ redirectUri: string;
11
+ state: string;
12
+ }): string;
13
+ exchangeCode(params: {
14
+ code: string;
15
+ redirectUri: string;
16
+ }): Promise<UpstreamTokens>;
17
+ refresh?(refreshToken: string): Promise<UpstreamTokens>;
18
+ }
19
+ export interface OAuth2UpstreamOptions {
20
+ authorizeUrl: string;
21
+ tokenUrl: string;
22
+ clientId: string;
23
+ clientSecret: string;
24
+ scopes: readonly string[];
25
+ fetch?: typeof globalThis.fetch;
26
+ }
27
+ export declare class UpstreamError extends Error {
28
+ constructor(message: string);
29
+ }
30
+ /** A standard authorization code provider that takes its client secret in the request body. */
31
+ export declare function oauth2Upstream(options: OAuth2UpstreamOptions): OAuthUpstream;
@@ -0,0 +1,65 @@
1
+ export class UpstreamError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'UpstreamError';
5
+ }
6
+ }
7
+ /** A standard authorization code provider that takes its client secret in the request body. */
8
+ export function oauth2Upstream(options) {
9
+ const fetchImpl = options.fetch ?? globalThis.fetch;
10
+ async function token(params) {
11
+ const response = await fetchImpl(options.tokenUrl, {
12
+ method: 'POST',
13
+ headers: {
14
+ 'content-type': 'application/x-www-form-urlencoded',
15
+ accept: 'application/json'
16
+ },
17
+ body: new URLSearchParams({
18
+ client_id: options.clientId,
19
+ client_secret: options.clientSecret,
20
+ ...params
21
+ })
22
+ });
23
+ let body = {};
24
+ try {
25
+ body = (await response.json());
26
+ }
27
+ catch {
28
+ // Handled below as a response without an access token.
29
+ }
30
+ if (!response.ok || typeof body.access_token !== 'string') {
31
+ const reason = typeof body.error_description === 'string'
32
+ ? body.error_description
33
+ : typeof body.error === 'string'
34
+ ? body.error
35
+ : `HTTP ${String(response.status)}`;
36
+ throw new UpstreamError(`Token request failed: ${reason}`);
37
+ }
38
+ return {
39
+ accessToken: body.access_token,
40
+ ...(typeof body.refresh_token === 'string'
41
+ ? { refreshToken: body.refresh_token }
42
+ : {}),
43
+ ...(typeof body.expires_in === 'number'
44
+ ? { expiresAt: Date.now() + body.expires_in * 1000 }
45
+ : {})
46
+ };
47
+ }
48
+ return {
49
+ authorizeUrl({ redirectUri, state }) {
50
+ const url = new URL(options.authorizeUrl);
51
+ url.searchParams.set('client_id', options.clientId);
52
+ url.searchParams.set('redirect_uri', redirectUri);
53
+ url.searchParams.set('response_type', 'code');
54
+ url.searchParams.set('scope', options.scopes.join(' '));
55
+ url.searchParams.set('state', state);
56
+ return url.toString();
57
+ },
58
+ exchangeCode: ({ code, redirectUri }) => token({
59
+ grant_type: 'authorization_code',
60
+ code,
61
+ redirect_uri: redirectUri
62
+ }),
63
+ refresh: (refreshToken) => token({ grant_type: 'refresh_token', refresh_token: refreshToken })
64
+ };
65
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ultimatelemon-eu/mcp-http",
3
- "version": "2.1.0",
4
- "description": "Run an MCP server over stateless Streamable HTTP: /mcp, /healthz, a base path for proxies, the caller's own API key per request, and errors that never leak a stack trace.",
3
+ "version": "2.3.0",
4
+ "description": "Run an MCP server over stateless Streamable HTTP: /mcp, /healthz, a base path for proxies, the caller's own API key or a stateless OAuth sign-in, and errors that never leak a stack trace.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -41,5 +41,8 @@
41
41
  "peerDependencies": {
42
42
  "@modelcontextprotocol/node": "^2.1.0",
43
43
  "@modelcontextprotocol/server": "^2.3.0"
44
+ },
45
+ "dependencies": {
46
+ "@ultimatelemon-eu/crypto": "^1.0.0"
44
47
  }
45
48
  }