melchizedek-agents 1.2.0 → 1.3.1
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/.env.example +9 -0
- package/CHANGELOG.md +174 -0
- package/DOCUMENTATION.md +107 -10
- package/ONBOARDING.md +50 -11
- package/QUICKSTART.md +1 -1
- package/README.md +22 -2
- package/config/agents/examples/connectors.yaml +82 -0
- package/config/agents/syndicate.schema.json +324 -26
- package/dist/lib/a2a/app.js +3 -0
- package/dist/lib/a2a/oauthSetup.js +20 -5
- package/dist/lib/chatgpt/adapter.d.ts +50 -0
- package/dist/lib/chatgpt/adapter.js +107 -0
- package/dist/lib/chatgpt/cli.d.ts +23 -0
- package/dist/lib/chatgpt/cli.js +77 -0
- package/dist/lib/chatgpt/oauth.d.ts +104 -0
- package/dist/lib/chatgpt/oauth.js +472 -0
- package/dist/lib/chatgpt/state.d.ts +98 -0
- package/dist/lib/chatgpt/state.js +145 -0
- package/dist/lib/chatgpt/store.d.ts +24 -0
- package/dist/lib/chatgpt/store.js +94 -0
- package/dist/lib/compile.js +40 -25
- package/dist/lib/dispatch.js +6 -2
- package/dist/lib/doctor.d.ts +34 -3
- package/dist/lib/doctor.js +63 -5
- package/dist/lib/loadSyndicate.d.ts +6 -0
- package/dist/lib/mcp/server.d.ts +195 -0
- package/dist/lib/mcp/server.js +564 -0
- package/dist/lib/memory/supabaseMemoryService.js +2 -2
- package/dist/lib/models/adapterResolver.js +6 -1
- package/dist/lib/models/gateway.d.ts +1 -1
- package/dist/lib/models/gateway.js +4 -2
- package/dist/lib/models/gptAdapter.js +11 -1
- package/dist/lib/onboarding.d.ts +4 -3
- package/dist/lib/onboarding.js +48 -9
- package/dist/lib/runtime/syndicateTurn.js +30 -16
- package/dist/lib/syndicateSchema.d.ts +146 -8
- package/dist/lib/syndicateSchema.js +118 -11
- package/dist/lib/tools/credentialUses.d.ts +7 -1
- package/dist/lib/tools/credentialUses.js +10 -2
- package/dist/lib/tools/generateImageTool.js +2 -1
- package/dist/lib/tools/inspectImageTool.js +1 -1
- package/dist/lib/tools/mcpToolFactory.d.ts +29 -1
- package/dist/lib/tools/mcpToolFactory.js +71 -25
- package/dist/lib/tools/mcpTransports.d.ts +12 -0
- package/dist/lib/tools/mcpTransports.js +11 -0
- package/dist/lib/tools/oauthConsent.d.ts +18 -3
- package/dist/lib/tools/oauthConsent.js +49 -14
- package/dist/lib/tools/oauthDiscovery.d.ts +202 -0
- package/dist/lib/tools/oauthDiscovery.js +616 -0
- package/dist/lib/tools/oauthTools.d.ts +44 -12
- package/dist/lib/tools/oauthTools.js +79 -22
- package/dist/lib/tools/xApiSearchTool.js +2 -2
- package/dist/scripts/a2a_server.js +8 -0
- package/dist/scripts/assistant_worker.js +11 -1
- package/dist/scripts/doctor.d.ts +1 -1
- package/dist/scripts/doctor.js +1 -1
- package/dist/scripts/mcp_server.d.ts +12 -0
- package/dist/scripts/mcp_server.js +345 -0
- package/dist/scripts/setup.d.ts +2 -0
- package/dist/scripts/setup.js +27 -0
- package/dist/scripts/syndicate_chat.js +12 -3
- package/package.json +15 -12
- package/skills/README.md +2 -2
- package/skills/briefs/skills-readme.md +1 -1
- package/skills/melchizedek-onboard/SKILL.md +28 -6
- package/skills/melchizedek-onboard-keys/SKILL.md +17 -5
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/oauthDiscovery.ts — an MCP server's authorization server,
|
|
3
|
+
* discovered, and this deployment registered at it as an OAuth client
|
|
4
|
+
* (ADR 0124).
|
|
5
|
+
*
|
|
6
|
+
* WHY this file exists:
|
|
7
|
+
* The official remote MCP servers (an issue tracker's, a wiki's, a code
|
|
8
|
+
* host's) take each user's own OAuth token, and most issue no client id in
|
|
9
|
+
* advance: the MCP authorization spec has a client find the authorization
|
|
10
|
+
* server from the MCP server (protected-resource metadata, RFC 9728; then
|
|
11
|
+
* authorization-server metadata, RFC 8414 or OpenID discovery) and register
|
|
12
|
+
* itself (dynamic client registration, RFC 7591). A YAML says so with
|
|
13
|
+
* `client_registration: dynamic` on the server's `oauth2` grant; this module
|
|
14
|
+
* does the discovery and the registration, and keeps the registered client.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT GUARANTEES:
|
|
17
|
+
* - The MCP server is asked first, as the MCP authorization spec has a
|
|
18
|
+
* client do: an unauthenticated initialize request, and on a 401 the
|
|
19
|
+
* Bearer challenge's `resource_metadata` (and `scope`) parameters in its
|
|
20
|
+
* WWW-Authenticate header (RFC 9728 5.1). The URL it names is read first;
|
|
21
|
+
* the well-known URLs are the fallback. The header is read by a linear,
|
|
22
|
+
* length-bounded tokenizer (no regular expression), and never logged.
|
|
23
|
+
* - Discovery never widens the operator's OAuth host allowlist (ADR 0114):
|
|
24
|
+
* every URL it fetches (the MCP server's challenge, the metadata
|
|
25
|
+
* documents, including one the header names, the registration
|
|
26
|
+
* endpoint) and every endpoint it returns (authorization, token) must be
|
|
27
|
+
* a host the allowlist binds to the provider, checked before the request
|
|
28
|
+
* and again whenever the client is used. A discovered host outside it is
|
|
29
|
+
* refused by name, not skipped.
|
|
30
|
+
* - Every request passes the transport rule (https, or http to a loopback
|
|
31
|
+
* host) and the SSRF guard (lib/net/addressGuard.ts) unless private hosts
|
|
32
|
+
* are allowed (ALLOW_PRIVATE_MCP), follows no redirect, has a time limit,
|
|
33
|
+
* and reads a bounded JSON body.
|
|
34
|
+
* - The authorization server must advertise PKCE S256; one that does not is
|
|
35
|
+
* refused (MCP authorization spec). The client registers as a public
|
|
36
|
+
* client (`token_endpoint_auth_method: none`) for the one configured
|
|
37
|
+
* redirect URI, with the authorization_code and refresh_token grants.
|
|
38
|
+
* - The registered client is kept per provider, MCP server and redirect URI
|
|
39
|
+
* in the credential store's rows, sealed by its cipher
|
|
40
|
+
* (MELCHIZEDEK_CREDENTIAL_KEY), under an app name no run can have (it
|
|
41
|
+
* carries `:`, which a memory namespace cannot), so no tool call can read
|
|
42
|
+
* it as a token. A row this key cannot open, an expired client secret, or
|
|
43
|
+
* endpoints that moved are registered again.
|
|
44
|
+
* - No client secret, metadata body or server error text reaches a log
|
|
45
|
+
* line, an error message or a result: refusals name roles and hosts.
|
|
46
|
+
*/
|
|
47
|
+
import { createHash } from 'node:crypto';
|
|
48
|
+
import { checkHost } from '../net/addressGuard.js';
|
|
49
|
+
import { OAUTH_SCOPE, PROVIDER_NAME } from './auth.js';
|
|
50
|
+
import { credentialContext } from './credentialStore.js';
|
|
51
|
+
import { oauthHostProblem, oauthHosts } from './oauthHosts.js';
|
|
52
|
+
/** A discovery or registration that cannot proceed. The message names roles and hosts, never a value. */
|
|
53
|
+
export class OAuthDiscoveryError extends Error {
|
|
54
|
+
provider;
|
|
55
|
+
constructor(provider, message) {
|
|
56
|
+
// An allowlist refusal already names its provider (lib/tools/oauthHosts.ts).
|
|
57
|
+
super(message.startsWith('provider "') ? message : `provider "${provider}": ${message}`);
|
|
58
|
+
this.name = 'OAuthDiscoveryError';
|
|
59
|
+
this.provider = provider;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const MAX_METADATA_BYTES = 64 * 1024;
|
|
63
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
64
|
+
/** The URL, or a refusal naming its role: https, or http to a loopback host, and no credentials in it. */
|
|
65
|
+
function endpointUrl(provider, raw, role) {
|
|
66
|
+
if (typeof raw !== 'string' || raw.length > 2048)
|
|
67
|
+
throw new OAuthDiscoveryError(provider, `the ${role} is missing or not a URL`);
|
|
68
|
+
let url;
|
|
69
|
+
try {
|
|
70
|
+
url = new URL(raw);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new OAuthDiscoveryError(provider, `the ${role} is not a URL`);
|
|
74
|
+
}
|
|
75
|
+
if (url.username || url.password)
|
|
76
|
+
throw new OAuthDiscoveryError(provider, `the ${role} (${url.hostname}) carries credentials in its URL`);
|
|
77
|
+
if (url.hash)
|
|
78
|
+
throw new OAuthDiscoveryError(provider, `the ${role} (${url.hostname}) carries a fragment`);
|
|
79
|
+
const ok = url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname));
|
|
80
|
+
if (!ok)
|
|
81
|
+
throw new OAuthDiscoveryError(provider, `the ${role} (${url.hostname}) is not https (http only to a loopback host)`);
|
|
82
|
+
return url;
|
|
83
|
+
}
|
|
84
|
+
/** The MCP server's canonical URI (RFC 8707, MCP authorization spec): no fragment, no trailing slash on a path. */
|
|
85
|
+
export function canonicalResource(serverUrl) {
|
|
86
|
+
const url = new URL(serverUrl);
|
|
87
|
+
url.hash = '';
|
|
88
|
+
const path = url.pathname === '/' ? '' : url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname;
|
|
89
|
+
return `${url.protocol}//${url.host}${path}${url.search}`;
|
|
90
|
+
}
|
|
91
|
+
/** Two identifiers equal but for a trailing slash. */
|
|
92
|
+
const sameId = (a, b) => (a.endsWith('/') ? a.slice(0, -1) : a) === (b.endsWith('/') ? b.slice(0, -1) : b);
|
|
93
|
+
/**
|
|
94
|
+
* Discovery and registration's one way out: the URL checked against the
|
|
95
|
+
* operator's allowlist for the provider and the SSRF guard first (refusal
|
|
96
|
+
* throws, and no request is made), then one request with no redirect and a
|
|
97
|
+
* time limit. `undefined` when the host could not be reached.
|
|
98
|
+
*/
|
|
99
|
+
async function guardedFetch(url, role, options, init = {}) {
|
|
100
|
+
const { provider } = options;
|
|
101
|
+
const refused = oauthHostProblem({ provider, grant: 'authorization_code' }, url.href, role, options.list);
|
|
102
|
+
if (refused)
|
|
103
|
+
throw new OAuthDiscoveryError(provider, `${refused} (discovery never widens the allowlist)`);
|
|
104
|
+
if (options.allowPrivate !== true) {
|
|
105
|
+
const reason = await checkHost(url.hostname);
|
|
106
|
+
if (reason)
|
|
107
|
+
throw new OAuthDiscoveryError(provider, `refusing the ${role} ${url.hostname}: ${reason}`);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return await (options.fetch ?? fetch)(url.href, {
|
|
111
|
+
...init,
|
|
112
|
+
headers: { Accept: 'application/json', ...init.headers },
|
|
113
|
+
// A redirect would take the request (and, for registration, the redirect URI) wherever it points.
|
|
114
|
+
redirect: 'error',
|
|
115
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A guarded request with a bounded JSON body. Refusal or an unreachable host
|
|
124
|
+
* throws; a non-2xx answer or an unusable body is `undefined` for a metadata
|
|
125
|
+
* candidate, so the next one can be tried.
|
|
126
|
+
*/
|
|
127
|
+
async function guardedJson(url, role, options, init = {}) {
|
|
128
|
+
const res = await guardedFetch(url, role, options, init);
|
|
129
|
+
if (!res)
|
|
130
|
+
throw new OAuthDiscoveryError(options.provider, `the ${role} (${url.hostname}) could not be reached`);
|
|
131
|
+
const reader = res.body?.getReader();
|
|
132
|
+
if (!reader)
|
|
133
|
+
return { status: res.status, json: undefined };
|
|
134
|
+
const chunks = [];
|
|
135
|
+
let size = 0;
|
|
136
|
+
for (;;) {
|
|
137
|
+
const { done, value } = await reader.read();
|
|
138
|
+
if (done)
|
|
139
|
+
break;
|
|
140
|
+
size += value.byteLength;
|
|
141
|
+
if (size > MAX_METADATA_BYTES) {
|
|
142
|
+
await reader.cancel().catch(() => { });
|
|
143
|
+
return { status: res.status, json: undefined };
|
|
144
|
+
}
|
|
145
|
+
chunks.push(value);
|
|
146
|
+
}
|
|
147
|
+
if (!res.ok)
|
|
148
|
+
return { status: res.status, json: undefined };
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
151
|
+
return { status: res.status, json: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined };
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return { status: res.status, json: undefined };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// ── The MCP server's 401 challenge (RFC 9728 5.1, RFC 6750 3, RFC 9110 11.6.1) ──
|
|
158
|
+
/** A WWW-Authenticate header longer than this is ignored whole. */
|
|
159
|
+
export const MAX_CHALLENGE_HEADER = 8 * 1024;
|
|
160
|
+
/** A parameter value longer than this is ignored. */
|
|
161
|
+
const MAX_CHALLENGE_VALUE = 2048;
|
|
162
|
+
const MAX_CHALLENGE_SCOPES = 64;
|
|
163
|
+
/** RFC 9110 tchar, by code point: no regular expression on header input. */
|
|
164
|
+
function isTchar(c) {
|
|
165
|
+
if ((c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a))
|
|
166
|
+
return true;
|
|
167
|
+
// ! # $ % & ' * + - . ^ _ ` | ~
|
|
168
|
+
return c === 0x21 || (c >= 0x23 && c <= 0x27) || c === 0x2a || c === 0x2b || c === 0x2d || c === 0x2e || c === 0x5e || c === 0x5f || c === 0x60 || c === 0x7c || c === 0x7e;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The first Bearer challenge's parameters in a WWW-Authenticate header, or
|
|
172
|
+
* undefined (no Bearer challenge, or a header that is too long or malformed:
|
|
173
|
+
* either is ignored whole, and discovery falls back to the well-known URLs).
|
|
174
|
+
* One pass over the header, each step advancing: linear in its length, with
|
|
175
|
+
* no regular expression. Several challenges in one header, quoted-string
|
|
176
|
+
* values with escapes, case-insensitive scheme and parameter names, and a
|
|
177
|
+
* token68 credential on another scheme are understood. A parameter given
|
|
178
|
+
* twice in the Bearer challenge makes it ambiguous, and malformed.
|
|
179
|
+
*/
|
|
180
|
+
export function parseBearerChallenge(header) {
|
|
181
|
+
if (typeof header !== 'string' || header.length === 0 || header.length > MAX_CHALLENGE_HEADER)
|
|
182
|
+
return undefined;
|
|
183
|
+
const h = header;
|
|
184
|
+
const n = h.length;
|
|
185
|
+
let i = 0;
|
|
186
|
+
const ws = () => {
|
|
187
|
+
while (i < n && (h[i] === ' ' || h[i] === '\t'))
|
|
188
|
+
i++;
|
|
189
|
+
};
|
|
190
|
+
/** A token, or a token68 (which adds `/`); empty when none starts here. */
|
|
191
|
+
const word = () => {
|
|
192
|
+
const start = i;
|
|
193
|
+
while (i < n && (isTchar(h.charCodeAt(i)) || h[i] === '/'))
|
|
194
|
+
i++;
|
|
195
|
+
return h.slice(start, i);
|
|
196
|
+
};
|
|
197
|
+
let current = ''; // the scheme the parameters belong to, lowercased
|
|
198
|
+
let afterScheme = false; // a scheme was read, and no comma since: a bare word is its token68
|
|
199
|
+
let bearer;
|
|
200
|
+
while (i < n) {
|
|
201
|
+
ws();
|
|
202
|
+
if (i >= n)
|
|
203
|
+
break;
|
|
204
|
+
if (h[i] === ',') {
|
|
205
|
+
i++;
|
|
206
|
+
afterScheme = false;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const name = word();
|
|
210
|
+
if (!name)
|
|
211
|
+
return undefined; // a character no challenge can hold here
|
|
212
|
+
ws();
|
|
213
|
+
if (h[i] !== '=') {
|
|
214
|
+
if (afterScheme)
|
|
215
|
+
continue; // the scheme's token68 credential
|
|
216
|
+
if (bearer)
|
|
217
|
+
break; // the Bearer challenge is complete
|
|
218
|
+
current = name.toLowerCase();
|
|
219
|
+
afterScheme = true;
|
|
220
|
+
if (current === 'bearer')
|
|
221
|
+
bearer = new Map();
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// `=`: an auth-param, or the padding of a token68.
|
|
225
|
+
let eq = 0;
|
|
226
|
+
while (i < n && h[i] === '=') {
|
|
227
|
+
i++;
|
|
228
|
+
eq++;
|
|
229
|
+
}
|
|
230
|
+
ws();
|
|
231
|
+
if (eq > 1 || i >= n || h[i] === ',') {
|
|
232
|
+
if (!afterScheme)
|
|
233
|
+
return undefined; // padding with no scheme before it
|
|
234
|
+
continue; // token68 padding
|
|
235
|
+
}
|
|
236
|
+
if (name.includes('/'))
|
|
237
|
+
return undefined; // not a parameter name
|
|
238
|
+
let value;
|
|
239
|
+
if (h[i] === '"') {
|
|
240
|
+
i++;
|
|
241
|
+
const parts = [];
|
|
242
|
+
let length = 0;
|
|
243
|
+
let closed = false;
|
|
244
|
+
while (i < n) {
|
|
245
|
+
const ch = h[i];
|
|
246
|
+
if (ch === '"') {
|
|
247
|
+
i++;
|
|
248
|
+
closed = true;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
if (ch === '\\') {
|
|
252
|
+
i++;
|
|
253
|
+
if (i >= n)
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
if (length <= MAX_CHALLENGE_VALUE) {
|
|
257
|
+
parts.push(h[i]);
|
|
258
|
+
length++;
|
|
259
|
+
}
|
|
260
|
+
i++;
|
|
261
|
+
}
|
|
262
|
+
if (!closed)
|
|
263
|
+
return undefined;
|
|
264
|
+
value = parts.join('');
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
value = word();
|
|
268
|
+
if (!value)
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
ws();
|
|
272
|
+
if (i < n && h[i] !== ',')
|
|
273
|
+
return undefined; // a parameter runs into something else
|
|
274
|
+
afterScheme = false;
|
|
275
|
+
if (current === 'bearer' && bearer) {
|
|
276
|
+
const key = name.toLowerCase();
|
|
277
|
+
if (bearer.has(key))
|
|
278
|
+
return undefined;
|
|
279
|
+
bearer.set(key, value);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (!bearer)
|
|
283
|
+
return undefined;
|
|
284
|
+
const out = {};
|
|
285
|
+
const metadata = bearer.get('resource_metadata');
|
|
286
|
+
if (metadata && metadata.length <= MAX_CHALLENGE_VALUE)
|
|
287
|
+
out.resourceMetadata = metadata;
|
|
288
|
+
const scope = bearer.get('scope');
|
|
289
|
+
if (scope && scope.length <= MAX_CHALLENGE_VALUE) {
|
|
290
|
+
const scopes = [...new Set(scope.split(' ').filter((s) => s && OAUTH_SCOPE.test(s)))].slice(0, MAX_CHALLENGE_SCOPES);
|
|
291
|
+
if (scopes.length)
|
|
292
|
+
out.scopes = scopes;
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
/** The request that draws the challenge: an MCP initialize with no token, as any client's first request is. */
|
|
297
|
+
const PROBE_BODY = JSON.stringify({
|
|
298
|
+
jsonrpc: '2.0',
|
|
299
|
+
id: 0,
|
|
300
|
+
method: 'initialize',
|
|
301
|
+
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'melchizedek-oauth-discovery', version: '1' } },
|
|
302
|
+
});
|
|
303
|
+
/**
|
|
304
|
+
* The MCP server's Bearer challenge, when an unauthenticated initialize is
|
|
305
|
+
* answered 401 with one. Refused like every request (allowlist, SSRF guard)
|
|
306
|
+
* before it is sent; any other answer, or no answer, is undefined and
|
|
307
|
+
* discovery uses the well-known URLs. The body is never read, and the header
|
|
308
|
+
* is never logged.
|
|
309
|
+
*/
|
|
310
|
+
async function serverChallenge(server, options) {
|
|
311
|
+
const res = await guardedFetch(server, 'MCP server', options, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
|
|
314
|
+
body: PROBE_BODY,
|
|
315
|
+
});
|
|
316
|
+
if (!res)
|
|
317
|
+
return undefined;
|
|
318
|
+
await res.body?.cancel().catch(() => { });
|
|
319
|
+
if (res.status !== 401)
|
|
320
|
+
return undefined;
|
|
321
|
+
return parseBearerChallenge(res.headers.get('www-authenticate'));
|
|
322
|
+
}
|
|
323
|
+
/** RFC 9728 3.1: the protected-resource metadata URLs for a server, path-specific first. */
|
|
324
|
+
export function protectedResourceMetadataUrls(serverUrl) {
|
|
325
|
+
const url = new URL(serverUrl);
|
|
326
|
+
const path = url.pathname === '/' ? '' : url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname;
|
|
327
|
+
const out = [new URL(`/.well-known/oauth-protected-resource${path}`, url.origin)];
|
|
328
|
+
if (path)
|
|
329
|
+
out.push(new URL('/.well-known/oauth-protected-resource', url.origin));
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
/** RFC 8414 3.1 and OpenID discovery, in the order the MCP authorization spec gives. */
|
|
333
|
+
export function authorizationServerMetadataUrls(issuer) {
|
|
334
|
+
const url = new URL(issuer);
|
|
335
|
+
const path = url.pathname === '/' ? '' : url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname;
|
|
336
|
+
if (!path)
|
|
337
|
+
return [new URL('/.well-known/oauth-authorization-server', url.origin), new URL('/.well-known/openid-configuration', url.origin)];
|
|
338
|
+
return [
|
|
339
|
+
new URL(`/.well-known/oauth-authorization-server${path}`, url.origin),
|
|
340
|
+
new URL(`/.well-known/openid-configuration${path}`, url.origin),
|
|
341
|
+
new URL(`${path}/.well-known/openid-configuration`, url.origin),
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The MCP server's authorization server, as the MCP authorization spec finds
|
|
346
|
+
* it: the protected-resource metadata URL named by the server's 401 challenge
|
|
347
|
+
* (WWW-Authenticate, RFC 9728 5.1), then the well-known URLs (RFC 9728 3.1);
|
|
348
|
+
* the metadata names the authorization server (a server with none is its
|
|
349
|
+
* own, as the 2025-03-26 revision had it), and its own metadata (RFC 8414, or
|
|
350
|
+
* OpenID discovery) names the endpoints. Every host involved, the one the
|
|
351
|
+
* header names included, passes the provider's allowlist before any request;
|
|
352
|
+
* PKCE S256 and a registration endpoint are required. Throws
|
|
353
|
+
* OAuthDiscoveryError.
|
|
354
|
+
*/
|
|
355
|
+
export async function discoverAuthorization(serverUrl, options) {
|
|
356
|
+
const { provider } = options;
|
|
357
|
+
if (!PROVIDER_NAME.test(provider))
|
|
358
|
+
throw new OAuthDiscoveryError('oauth', 'the provider name is not valid');
|
|
359
|
+
const list = options.allowlist === undefined ? oauthHosts() ?? null : options.allowlist;
|
|
360
|
+
const opts = { ...options, list };
|
|
361
|
+
const server = endpointUrl(provider, serverUrl, 'MCP server');
|
|
362
|
+
const resource = canonicalResource(server.href);
|
|
363
|
+
const challenge = await serverChallenge(server, opts);
|
|
364
|
+
const candidates = [];
|
|
365
|
+
if (challenge?.resourceMetadata) {
|
|
366
|
+
// A value that is no URL at all is a malformed challenge, ignored; a URL is held to every rule, and refused by name.
|
|
367
|
+
let parsed;
|
|
368
|
+
try {
|
|
369
|
+
parsed = new URL(challenge.resourceMetadata);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
parsed = undefined;
|
|
373
|
+
}
|
|
374
|
+
const role = 'protected-resource metadata named by the 401';
|
|
375
|
+
if (parsed)
|
|
376
|
+
candidates.push({ url: endpointUrl(provider, parsed.href, role), role });
|
|
377
|
+
}
|
|
378
|
+
for (const url of protectedResourceMetadataUrls(server.href)) {
|
|
379
|
+
if (!candidates.some((c) => c.url.href === url.href))
|
|
380
|
+
candidates.push({ url, role: 'protected-resource metadata' });
|
|
381
|
+
}
|
|
382
|
+
let issuer;
|
|
383
|
+
for (const { url: candidate, role } of candidates) {
|
|
384
|
+
const { json } = await guardedJson(candidate, role, opts);
|
|
385
|
+
if (!json)
|
|
386
|
+
continue;
|
|
387
|
+
if (typeof json.resource === 'string' && !sameId(json.resource, resource)) {
|
|
388
|
+
throw new OAuthDiscoveryError(provider, `the protected-resource metadata at ${candidate.hostname} names another resource than the MCP server (RFC 9728 3.3)`);
|
|
389
|
+
}
|
|
390
|
+
const servers = Array.isArray(json.authorization_servers) ? json.authorization_servers : [];
|
|
391
|
+
if (typeof servers[0] !== 'string')
|
|
392
|
+
throw new OAuthDiscoveryError(provider, `the protected-resource metadata at ${candidate.hostname} names no authorization server`);
|
|
393
|
+
issuer = servers[0];
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
// No protected-resource metadata: the MCP server's origin is its authorization server (MCP 2025-03-26).
|
|
397
|
+
const issuerUrl = endpointUrl(provider, issuer ?? server.origin, 'authorization server');
|
|
398
|
+
const issuerId = issuer ?? server.origin;
|
|
399
|
+
let metadata;
|
|
400
|
+
for (const candidate of authorizationServerMetadataUrls(issuerUrl.href)) {
|
|
401
|
+
const { json } = await guardedJson(candidate, 'authorization server metadata', opts);
|
|
402
|
+
if (!json)
|
|
403
|
+
continue;
|
|
404
|
+
if (typeof json.issuer !== 'string' || !sameId(json.issuer, issuerId)) {
|
|
405
|
+
throw new OAuthDiscoveryError(provider, `the authorization server metadata at ${candidate.hostname} names another issuer (RFC 8414 3.3)`);
|
|
406
|
+
}
|
|
407
|
+
metadata = json;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
if (!metadata)
|
|
411
|
+
throw new OAuthDiscoveryError(provider, `no authorization server metadata at ${issuerUrl.hostname}`);
|
|
412
|
+
const methods = Array.isArray(metadata.code_challenge_methods_supported) ? metadata.code_challenge_methods_supported : [];
|
|
413
|
+
if (!methods.includes('S256'))
|
|
414
|
+
throw new OAuthDiscoveryError(provider, `the authorization server ${issuerUrl.hostname} does not advertise PKCE S256, which the MCP authorization spec requires`);
|
|
415
|
+
if (metadata.registration_endpoint === undefined) {
|
|
416
|
+
throw new OAuthDiscoveryError(provider, `the authorization server ${issuerUrl.hostname} offers no dynamic client registration; declare client_id and the endpoints instead`);
|
|
417
|
+
}
|
|
418
|
+
const endpoints = {
|
|
419
|
+
authorizationEndpoint: endpointUrl(provider, metadata.authorization_endpoint, 'authorization endpoint'),
|
|
420
|
+
tokenEndpoint: endpointUrl(provider, metadata.token_endpoint, 'token endpoint'),
|
|
421
|
+
registrationEndpoint: endpointUrl(provider, metadata.registration_endpoint, 'registration endpoint'),
|
|
422
|
+
};
|
|
423
|
+
// Discovery never widens the allowlist: each endpoint it found is one the operator bound to the provider.
|
|
424
|
+
for (const [role, url] of [['authorization_url', endpoints.authorizationEndpoint], ['token_url', endpoints.tokenEndpoint], ['registration endpoint', endpoints.registrationEndpoint]]) {
|
|
425
|
+
const refused = oauthHostProblem({ provider, grant: 'authorization_code' }, url.href, role, list);
|
|
426
|
+
if (refused)
|
|
427
|
+
throw new OAuthDiscoveryError(provider, `${refused} (discovery never widens the allowlist)`);
|
|
428
|
+
if (options.allowPrivate !== true) {
|
|
429
|
+
const reason = await checkHost(url.hostname);
|
|
430
|
+
if (reason)
|
|
431
|
+
throw new OAuthDiscoveryError(provider, `refusing the ${role} ${url.hostname}: ${reason}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
resource,
|
|
436
|
+
issuer: issuerId,
|
|
437
|
+
authorizationEndpoint: endpoints.authorizationEndpoint.href,
|
|
438
|
+
tokenEndpoint: endpoints.tokenEndpoint.href,
|
|
439
|
+
registrationEndpoint: endpoints.registrationEndpoint.href,
|
|
440
|
+
...(challenge?.scopes ? { challengeScopes: challenge.scopes } : {}),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const CLIENT_ID_MAX = 512;
|
|
444
|
+
const CLIENT_SECRET_MAX = 2048;
|
|
445
|
+
/**
|
|
446
|
+
* Registers this deployment at the authorization server (RFC 7591) as a
|
|
447
|
+
* public PKCE client for the one redirect URI. Throws OAuthDiscoveryError,
|
|
448
|
+
* never the server's text.
|
|
449
|
+
*/
|
|
450
|
+
export async function registerOAuthClient(discovered, options) {
|
|
451
|
+
const { provider } = options;
|
|
452
|
+
const list = options.allowlist === undefined ? oauthHosts() ?? null : options.allowlist;
|
|
453
|
+
const endpoint = endpointUrl(provider, discovered.registrationEndpoint, 'registration endpoint');
|
|
454
|
+
const body = {
|
|
455
|
+
client_name: options.clientName ?? 'Melchizedek agents',
|
|
456
|
+
redirect_uris: [options.redirectUri],
|
|
457
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
458
|
+
response_types: ['code'],
|
|
459
|
+
token_endpoint_auth_method: 'none',
|
|
460
|
+
};
|
|
461
|
+
if (options.scopes?.length)
|
|
462
|
+
body.scope = options.scopes.join(' ');
|
|
463
|
+
const { status, json } = await guardedJson(endpoint, 'registration endpoint', { ...options, list }, {
|
|
464
|
+
method: 'POST',
|
|
465
|
+
headers: { 'Content-Type': 'application/json' },
|
|
466
|
+
body: JSON.stringify(body),
|
|
467
|
+
});
|
|
468
|
+
if (status !== 200 && status !== 201)
|
|
469
|
+
throw new OAuthDiscoveryError(provider, `the registration endpoint ${endpoint.hostname} refused the registration (HTTP ${status})`);
|
|
470
|
+
const clientId = json?.client_id;
|
|
471
|
+
if (typeof clientId !== 'string' || !clientId || clientId.length > CLIENT_ID_MAX)
|
|
472
|
+
throw new OAuthDiscoveryError(provider, `the registration endpoint ${endpoint.hostname} answered without a usable client_id`);
|
|
473
|
+
const secret = json?.client_secret;
|
|
474
|
+
if (secret !== undefined && (typeof secret !== 'string' || !secret || secret.length > CLIENT_SECRET_MAX)) {
|
|
475
|
+
throw new OAuthDiscoveryError(provider, `the registration endpoint ${endpoint.hostname} answered with an unusable client_secret`);
|
|
476
|
+
}
|
|
477
|
+
const expires = json?.client_secret_expires_at;
|
|
478
|
+
return {
|
|
479
|
+
clientId,
|
|
480
|
+
...(typeof secret === 'string' ? { clientSecret: secret } : {}),
|
|
481
|
+
...(typeof secret === 'string' && typeof expires === 'number' && Number.isFinite(expires) && expires > 0 ? { secretExpiresAt: expires * 1000 } : {}),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
// ── The registered clients, sealed in the credential store's rows ───────────
|
|
485
|
+
/**
|
|
486
|
+
* The app name registered clients are filed under. It carries `:`, which a
|
|
487
|
+
* memory namespace (the app a run pins, `[A-Za-z0-9._-]`) cannot, so no
|
|
488
|
+
* run's credential key ever reaches one of these rows.
|
|
489
|
+
*/
|
|
490
|
+
export const REGISTERED_CLIENTS_APP = 'oauth-client:dcr';
|
|
491
|
+
/** The row a provider's registered client is kept in: one per MCP server (resource) and redirect URI. */
|
|
492
|
+
export function registeredClientKey(provider, resource, redirectUri) {
|
|
493
|
+
return { appName: REGISTERED_CLIENTS_APP, userId: createHash('sha256').update(`${resource}\n${redirectUri}`).digest('hex'), provider };
|
|
494
|
+
}
|
|
495
|
+
/** Registered clients over the credential store's rows, sealed by its cipher (the same key as every user's tokens). */
|
|
496
|
+
export function oauthClientRegistry(options) {
|
|
497
|
+
const { rows, cipher } = options;
|
|
498
|
+
const now = options.now ?? Date.now;
|
|
499
|
+
return {
|
|
500
|
+
async get(key) {
|
|
501
|
+
const row = await rows.read(key);
|
|
502
|
+
if (!row || row.keyId !== cipher.keyId)
|
|
503
|
+
return undefined;
|
|
504
|
+
if (row.expiresAt && row.expiresAt.getTime() <= now())
|
|
505
|
+
return undefined;
|
|
506
|
+
try {
|
|
507
|
+
const parsed = JSON.parse(await cipher.decrypt(row.accessTokenEnc, credentialContext(key, 'access')));
|
|
508
|
+
if (parsed?.v !== 1 || typeof parsed.clientId !== 'string')
|
|
509
|
+
return undefined;
|
|
510
|
+
return { ...parsed, ...(row.expiresAt ? { secretExpiresAt: row.expiresAt.getTime() } : {}) };
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return undefined;
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
async put(key, registration, secretExpiresAt) {
|
|
517
|
+
const sealed = await cipher.encrypt(JSON.stringify({ v: 1, ...registration }), credentialContext(key, 'access'));
|
|
518
|
+
await rows.upsert({
|
|
519
|
+
appName: key.appName,
|
|
520
|
+
userId: key.userId,
|
|
521
|
+
provider: key.provider,
|
|
522
|
+
scopes: [],
|
|
523
|
+
accessTokenEnc: sealed,
|
|
524
|
+
refreshTokenEnc: null,
|
|
525
|
+
keyId: cipher.keyId,
|
|
526
|
+
expiresAt: secretExpiresAt ? new Date(secretExpiresAt) : null,
|
|
527
|
+
});
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* The consent step's client for a dynamic grant (oauthConsent and
|
|
533
|
+
* oauthRefreshProviders take it in place of a fixed config): discovered and
|
|
534
|
+
* registered on first use, kept, and re-checked against the allowlist in
|
|
535
|
+
* force on every use. A failure is not cached; the next use tries again.
|
|
536
|
+
*/
|
|
537
|
+
export function dynamicOAuthClient(grant, options) {
|
|
538
|
+
let resolved;
|
|
539
|
+
let pending;
|
|
540
|
+
const allowlistNow = () => (options.allowlist === undefined ? oauthHosts() ?? null : options.allowlist);
|
|
541
|
+
const resolve = async () => {
|
|
542
|
+
const discovered = await discoverAuthorization(grant.server, {
|
|
543
|
+
provider: grant.provider,
|
|
544
|
+
...(options.allowPrivate ? { allowPrivate: true } : {}),
|
|
545
|
+
...(options.allowlist !== undefined ? { allowlist: options.allowlist } : {}),
|
|
546
|
+
...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}),
|
|
547
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
548
|
+
});
|
|
549
|
+
// The YAML's scopes are the operator's choice; with none, the server's 401 challenge names them (MCP scope selection).
|
|
550
|
+
const scopes = grant.scopes.length ? grant.scopes : (discovered.challengeScopes ?? []);
|
|
551
|
+
const key = registeredClientKey(grant.provider, discovered.resource, options.redirectUri);
|
|
552
|
+
const kept = await options.registry?.get(key);
|
|
553
|
+
const current = kept &&
|
|
554
|
+
kept.issuer === discovered.issuer &&
|
|
555
|
+
kept.authorizationEndpoint === discovered.authorizationEndpoint &&
|
|
556
|
+
kept.tokenEndpoint === discovered.tokenEndpoint &&
|
|
557
|
+
kept.redirectUri === options.redirectUri
|
|
558
|
+
? kept
|
|
559
|
+
: undefined;
|
|
560
|
+
let clientId;
|
|
561
|
+
let clientSecret;
|
|
562
|
+
if (current) {
|
|
563
|
+
clientId = current.clientId;
|
|
564
|
+
clientSecret = current.clientSecret;
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
const registered = await registerOAuthClient(discovered, {
|
|
568
|
+
provider: grant.provider,
|
|
569
|
+
redirectUri: options.redirectUri,
|
|
570
|
+
scopes,
|
|
571
|
+
...(options.clientName ? { clientName: options.clientName } : {}),
|
|
572
|
+
...(options.allowPrivate ? { allowPrivate: true } : {}),
|
|
573
|
+
...(options.allowlist !== undefined ? { allowlist: options.allowlist } : {}),
|
|
574
|
+
...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}),
|
|
575
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
576
|
+
});
|
|
577
|
+
clientId = registered.clientId;
|
|
578
|
+
clientSecret = registered.clientSecret;
|
|
579
|
+
await options.registry?.put(key, {
|
|
580
|
+
clientId,
|
|
581
|
+
...(clientSecret ? { clientSecret } : {}),
|
|
582
|
+
issuer: discovered.issuer,
|
|
583
|
+
authorizationEndpoint: discovered.authorizationEndpoint,
|
|
584
|
+
tokenEndpoint: discovered.tokenEndpoint,
|
|
585
|
+
resource: discovered.resource,
|
|
586
|
+
redirectUri: options.redirectUri,
|
|
587
|
+
}, registered.secretExpiresAt);
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
authorizationUrl: discovered.authorizationEndpoint,
|
|
591
|
+
tokenUrl: discovered.tokenEndpoint,
|
|
592
|
+
clientId,
|
|
593
|
+
...(clientSecret ? { clientSecret } : {}),
|
|
594
|
+
scopes: [...scopes],
|
|
595
|
+
...(grant.authorizationParams ? { authorizationParams: { ...grant.authorizationParams } } : {}),
|
|
596
|
+
resource: discovered.resource,
|
|
597
|
+
};
|
|
598
|
+
};
|
|
599
|
+
return async () => {
|
|
600
|
+
if (resolved) {
|
|
601
|
+
// The allowlist can change after the client was found: checked on every use.
|
|
602
|
+
for (const [role, url] of [['authorization_url', resolved.authorizationUrl], ['token_url', resolved.tokenUrl]]) {
|
|
603
|
+
const refused = oauthHostProblem({ provider: grant.provider, grant: 'authorization_code' }, url, role, allowlistNow());
|
|
604
|
+
if (refused)
|
|
605
|
+
throw new OAuthDiscoveryError(grant.provider, refused);
|
|
606
|
+
}
|
|
607
|
+
return resolved;
|
|
608
|
+
}
|
|
609
|
+
if (!pending) {
|
|
610
|
+
pending = resolve()
|
|
611
|
+
.then((client) => (resolved = client))
|
|
612
|
+
.finally(() => (pending = undefined));
|
|
613
|
+
}
|
|
614
|
+
return pending;
|
|
615
|
+
};
|
|
616
|
+
}
|