@ultimatelemon-eu/mcp-http 2.2.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 +60 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/oauth.d.ts +25 -0
- package/dist/oauth.js +446 -0
- package/dist/pages.d.ts +24 -0
- package/dist/pages.js +85 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +83 -9
- package/dist/upstream.d.ts +31 -0
- package/dist/upstream.js +65 -0
- package/package.json +5 -2
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() }`.
|
|
@@ -57,9 +58,67 @@ before building an MCP server. The key reaches `createMcpServer` as
|
|
|
57
58
|
as headers of their own, read from `headers` in the same context.
|
|
58
59
|
|
|
59
60
|
This suits public servers where each user brings their own credentials: no
|
|
60
|
-
|
|
61
|
+
sessions, no database. In claude.ai, add the connector with
|
|
61
62
|
"No sign-in" and the key as a request header.
|
|
62
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
|
+
|
|
63
122
|
## Why stateless
|
|
64
123
|
|
|
65
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
package/dist/oauth.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/pages.d.ts
ADDED
|
@@ -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, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"');
|
|
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;
|
|
@@ -8,6 +9,10 @@ export interface RequestContext {
|
|
|
8
9
|
apiKey?: string;
|
|
9
10
|
/** Headers of the HTTP request, for per-caller settings beyond the key. */
|
|
10
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
|
+
};
|
|
11
16
|
}
|
|
12
17
|
export interface ApiKeyOptions {
|
|
13
18
|
/** Added to the 401 message, e.g. where to get a key. */
|
|
@@ -25,6 +30,8 @@ export interface McpHttpOptions {
|
|
|
25
30
|
health?: () => boolean | Promise<boolean>;
|
|
26
31
|
/** Require every MCP request to carry the caller's key in `X-Api-Key` or `Authorization: Bearer`. */
|
|
27
32
|
apiKey?: ApiKeyOptions;
|
|
33
|
+
/** Sign in with the upstream service instead of pasting a key. Needs `ENCRYPTION_KEY`. */
|
|
34
|
+
oauth?: OAuthOptions;
|
|
28
35
|
logger?: Logger;
|
|
29
36
|
}
|
|
30
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
|
|
40
|
+
function headerKey(req) {
|
|
39
41
|
const header = req.headers['x-api-key'];
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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,10 +119,20 @@ 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
127
|
const mcpHandler = createMcpHandler(({ authInfo, requestInfo }) => options.createMcpServer({
|
|
86
128
|
...(authInfo ? { apiKey: authInfo.token } : {}),
|
|
129
|
+
...(authInfo?.extra?.['settings']
|
|
130
|
+
? {
|
|
131
|
+
oauth: {
|
|
132
|
+
settings: authInfo.extra['settings']
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
87
136
|
headers: requestInfo?.headers ?? new Headers()
|
|
88
137
|
}), {
|
|
89
138
|
onerror: (error) => {
|
|
@@ -102,15 +151,36 @@ export async function startMcpHttpServer(options) {
|
|
|
102
151
|
jsonRpcError(res, 405, 'Method not allowed');
|
|
103
152
|
return;
|
|
104
153
|
}
|
|
105
|
-
if (!options.apiKey) {
|
|
154
|
+
if (!options.apiKey && !oauth) {
|
|
106
155
|
await nodeHandler(req, res);
|
|
107
156
|
return;
|
|
108
157
|
}
|
|
109
|
-
const
|
|
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;
|
|
110
178
|
if (!apiKey) {
|
|
111
|
-
const hint = options.apiKey
|
|
112
|
-
res.setHeader('www-authenticate', 'Bearer');
|
|
113
|
-
jsonRpcError(res, 401,
|
|
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.');
|
|
114
184
|
return;
|
|
115
185
|
}
|
|
116
186
|
await nodeHandler(Object.assign(req, {
|
|
@@ -128,6 +198,10 @@ export async function startMcpHttpServer(options) {
|
|
|
128
198
|
await handleMcp(req, res);
|
|
129
199
|
return;
|
|
130
200
|
}
|
|
201
|
+
if (oauth?.owns(path)) {
|
|
202
|
+
await sendResponse(res, await oauth.handle(await toRequest(req)));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
131
205
|
sendJson(res, 404, { error: 'Not found' });
|
|
132
206
|
}
|
|
133
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;
|
package/dist/upstream.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Run an MCP server over stateless Streamable HTTP: /mcp, /healthz, a base path for proxies, the caller's own API key
|
|
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
|
}
|