@every-app/sdk 0.3.0-rc.11 → 0.3.0-rc.13
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 +6 -4
- package/dist/cloudflare/worker.d.ts +1 -1
- package/dist/cloudflare/worker.d.ts.map +1 -1
- package/dist/cloudflare/worker.js +1 -2
- package/dist/internal/aiDefault.d.ts +32 -76
- package/dist/internal/aiDefault.d.ts.map +1 -1
- package/dist/internal/aiDefault.js +41 -144
- package/dist/internal/appSettings.d.ts +26 -19
- package/dist/internal/appSettings.d.ts.map +1 -1
- package/dist/internal/appSettings.js +24 -31
- package/dist/internal/capabilities.d.ts +21 -8
- package/dist/internal/capabilities.d.ts.map +1 -1
- package/dist/internal/capabilities.js +2 -2
- package/dist/internal/catalog/commerce.js +1 -1
- package/dist/internal/gatewaySettings.d.ts +6 -16
- package/dist/internal/gatewaySettings.d.ts.map +1 -1
- package/dist/internal/gatewaySettings.js +5 -23
- package/dist/internal/index.d.ts +1 -0
- package/dist/internal/index.d.ts.map +1 -1
- package/dist/internal/index.js +1 -0
- package/dist/internal/mcpOAuth.d.ts +210 -0
- package/dist/internal/mcpOAuth.d.ts.map +1 -0
- package/dist/internal/mcpOAuth.js +735 -0
- package/dist/internal/oauthBuiltIns.d.ts +1 -1
- package/dist/internal/oauthBuiltIns.js +1 -1
- package/dist/internal/oauthSpec.d.ts +1 -1
- package/dist/internal/oauthSpec.d.ts.map +1 -1
- package/dist/internal/oauthSpec.js +51 -1
- package/dist/internal/protocol.d.ts +5 -24
- package/dist/internal/protocol.d.ts.map +1 -1
- package/dist/internal/protocol.js +0 -19
- package/dist/internal/providerRegistry.d.ts +4 -3
- package/dist/internal/providerRegistry.d.ts.map +1 -1
- package/dist/internal/providerRegistry.js +11 -3
- package/dist/internal/remoteMcp.d.ts +162 -47
- package/dist/internal/remoteMcp.d.ts.map +1 -1
- package/dist/internal/remoteMcp.js +261 -124
- package/dist/internal/tools.d.ts +11 -7
- package/dist/internal/tools.d.ts.map +1 -1
- package/dist/internal/tools.js +8 -8
- package/dist/manifest.d.ts +9 -25
- package/dist/manifest.d.ts.map +1 -1
- package/dist/server/ai.d.ts +1 -93
- package/dist/server/ai.d.ts.map +1 -1
- package/dist/server/ai.js +17 -111
- package/dist/server/attribution.d.ts +1 -1
- package/dist/server/context.d.ts +12 -14
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/context.js +23 -25
- package/dist/server/everyApp.d.ts +4 -23
- package/dist/server/everyApp.d.ts.map +1 -1
- package/dist/server/everyApp.js +13 -46
- package/dist/server/gatewayFetch.d.ts +1 -1
- package/dist/server/gatewayFetch.js +1 -1
- package/dist/server/index.d.ts +3 -7
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -7
- package/dist/server/integrationFetch.d.ts +1 -1
- package/dist/server/refusal.d.ts +0 -9
- package/dist/server/refusal.d.ts.map +1 -1
- package/dist/server/refusal.js +0 -22
- package/dist/server/tools.d.ts +11 -8
- package/dist/server/tools.d.ts.map +1 -1
- package/dist/server/tools.js +10 -4
- package/dist/server/toolsEndpoint.d.ts +2 -3
- package/dist/server/toolsEndpoint.d.ts.map +1 -1
- package/dist/server/toolsEndpoint.js +6 -11
- package/dist/testing/index.d.ts +2 -3
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +2 -6
- package/package.json +1 -1
- package/dist/server/aiBinding.d.ts +0 -68
- package/dist/server/aiBinding.d.ts.map +0 -1
- package/dist/server/aiBinding.js +0 -294
- package/dist/server/emailBinding.d.ts +0 -72
- package/dist/server/emailBinding.d.ts.map +0 -1
- package/dist/server/emailBinding.js +0 -124
- package/dist/server/fromMcpTools.d.ts +0 -89
- package/dist/server/fromMcpTools.d.ts.map +0 -1
- package/dist/server/fromMcpTools.js +0 -192
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import { publicHostProblem } from "./providerRegistry.js";
|
|
2
|
+
import { parseProviderSpec, describeSpecProblems } from "./providerSpec.js";
|
|
3
|
+
import { remoteMcpClientIdSetting, remoteMcpClientSecretSetting, remoteMcpHeaderProblem, remoteMcpProviderId, remoteMcpTokenSetting, } from "./remoteMcp.js";
|
|
4
|
+
/** The largest answer a discovery or registration fetch reads: 64 KiB. */
|
|
5
|
+
export const MCP_OAUTH_MAX_BODY = 64 * 1024;
|
|
6
|
+
/** How long one discovery or registration fetch may take. */
|
|
7
|
+
export const MCP_OAUTH_TIMEOUT_MS = 10_000;
|
|
8
|
+
const TOKEN_CHAR = /[!#$%&'*+\-.^_`|~0-9A-Za-z]/;
|
|
9
|
+
/**
|
|
10
|
+
* The Bearer challenge of a `WWW-Authenticate` header, or null when the
|
|
11
|
+
* header carries none. Reads several challenges in one header
|
|
12
|
+
* (`Basic realm="x", Bearer resource_metadata="…"`), quoted and bare
|
|
13
|
+
* values, and parameter names in any case.
|
|
14
|
+
*/
|
|
15
|
+
export function parseWwwAuthenticate(header) {
|
|
16
|
+
if (typeof header !== "string" || !header.trim())
|
|
17
|
+
return null;
|
|
18
|
+
const challenges = [];
|
|
19
|
+
let current = null;
|
|
20
|
+
let i = 0;
|
|
21
|
+
const n = header.length;
|
|
22
|
+
const skip = (chars) => {
|
|
23
|
+
while (i < n && chars.test(header[i]))
|
|
24
|
+
i++;
|
|
25
|
+
};
|
|
26
|
+
const token = () => {
|
|
27
|
+
const start = i;
|
|
28
|
+
while (i < n && TOKEN_CHAR.test(header[i]))
|
|
29
|
+
i++;
|
|
30
|
+
return header.slice(start, i);
|
|
31
|
+
};
|
|
32
|
+
while (i < n) {
|
|
33
|
+
skip(/[\s,]/);
|
|
34
|
+
if (i >= n)
|
|
35
|
+
break;
|
|
36
|
+
const name = token();
|
|
37
|
+
if (!name) {
|
|
38
|
+
// Something unreadable: skip to the next comma.
|
|
39
|
+
while (i < n && header[i] !== ",")
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const afterName = i;
|
|
44
|
+
skip(/[ \t]/);
|
|
45
|
+
if (header[i] === "=" && header[i + 1] !== "=") {
|
|
46
|
+
i++;
|
|
47
|
+
skip(/[ \t]/);
|
|
48
|
+
let value = "";
|
|
49
|
+
if (header[i] === '"') {
|
|
50
|
+
i++;
|
|
51
|
+
while (i < n && header[i] !== '"') {
|
|
52
|
+
if (header[i] === "\\" && i + 1 < n)
|
|
53
|
+
i++;
|
|
54
|
+
value += header[i];
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const start = i;
|
|
61
|
+
while (i < n && header[i] !== "," && !/\s/.test(header[i]))
|
|
62
|
+
i++;
|
|
63
|
+
value = header.slice(start, i);
|
|
64
|
+
}
|
|
65
|
+
if (current)
|
|
66
|
+
current.params[name.toLowerCase()] = value;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// A scheme: a new challenge. (A token68 after another scheme reads
|
|
70
|
+
// as a scheme of its own; only the Bearer challenge is looked at.)
|
|
71
|
+
i = afterName;
|
|
72
|
+
current = { scheme: name.toLowerCase(), params: {} };
|
|
73
|
+
challenges.push(current);
|
|
74
|
+
}
|
|
75
|
+
const bearer = challenges.find((challenge) => challenge.scheme === "bearer");
|
|
76
|
+
if (!bearer)
|
|
77
|
+
return null;
|
|
78
|
+
const out = {};
|
|
79
|
+
if (bearer.params["resource_metadata"]) {
|
|
80
|
+
out.resourceMetadata = bearer.params["resource_metadata"];
|
|
81
|
+
}
|
|
82
|
+
if (bearer.params["scope"])
|
|
83
|
+
out.scope = bearer.params["scope"];
|
|
84
|
+
if (bearer.params["error"])
|
|
85
|
+
out.error = bearer.params["error"];
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Why `url` can't be fetched by discovery, or null when it can. */
|
|
89
|
+
export function mcpOAuthUrlProblem(url, { query = true } = {}) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = new URL(url);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return `${url} isn't a URL`;
|
|
96
|
+
}
|
|
97
|
+
if (parsed.protocol !== "https:")
|
|
98
|
+
return `${url} isn't https`;
|
|
99
|
+
if (parsed.username || parsed.password) {
|
|
100
|
+
return `${url} carries credentials`;
|
|
101
|
+
}
|
|
102
|
+
if (parsed.hash || url.includes("#"))
|
|
103
|
+
return `${url} has a fragment`;
|
|
104
|
+
if (parsed.port) {
|
|
105
|
+
return `${url} is on port ${parsed.port}; the Gateway's OAuth requests go to port 443 only`;
|
|
106
|
+
}
|
|
107
|
+
if (!query && parsed.search)
|
|
108
|
+
return `${url} carries a query string`;
|
|
109
|
+
const host = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
|
110
|
+
if (host.startsWith("[") || host.includes(":")) {
|
|
111
|
+
return `${host} is an IP address, not a host name`;
|
|
112
|
+
}
|
|
113
|
+
const problem = publicHostProblem(host);
|
|
114
|
+
if (problem)
|
|
115
|
+
return problem;
|
|
116
|
+
if (!host.includes("."))
|
|
117
|
+
return `${host} is not a public host`;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
async function readBounded(response) {
|
|
121
|
+
const declared = Number(response.headers.get("content-length") ?? "");
|
|
122
|
+
if (Number.isFinite(declared) && declared > MCP_OAUTH_MAX_BODY)
|
|
123
|
+
return null;
|
|
124
|
+
if (!response.body)
|
|
125
|
+
return "";
|
|
126
|
+
const reader = response.body.getReader();
|
|
127
|
+
const chunks = [];
|
|
128
|
+
let size = 0;
|
|
129
|
+
for (;;) {
|
|
130
|
+
const { done, value } = await reader.read();
|
|
131
|
+
if (done)
|
|
132
|
+
break;
|
|
133
|
+
size += value.byteLength;
|
|
134
|
+
if (size > MCP_OAUTH_MAX_BODY) {
|
|
135
|
+
await reader.cancel().catch(() => { });
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
chunks.push(value);
|
|
139
|
+
}
|
|
140
|
+
const bytes = new Uint8Array(size);
|
|
141
|
+
let offset = 0;
|
|
142
|
+
for (const chunk of chunks) {
|
|
143
|
+
bytes.set(chunk, offset);
|
|
144
|
+
offset += chunk.byteLength;
|
|
145
|
+
}
|
|
146
|
+
return new TextDecoder().decode(bytes);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* One bounded fetch: a JSON object, `missing` for a 4xx or 5xx (the next
|
|
150
|
+
* candidate is tried), `failed` for anything that ends discovery (a
|
|
151
|
+
* redirect, an oversize or unreadable answer, a network error, a refused
|
|
152
|
+
* URL).
|
|
153
|
+
*/
|
|
154
|
+
async function fetchJson(fetcher, url, init = {}) {
|
|
155
|
+
const problem = mcpOAuthUrlProblem(url);
|
|
156
|
+
if (problem)
|
|
157
|
+
return { kind: "failed", problem };
|
|
158
|
+
let response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetcher(url, {
|
|
161
|
+
...init,
|
|
162
|
+
redirect: "manual",
|
|
163
|
+
signal: AbortSignal.timeout(MCP_OAUTH_TIMEOUT_MS),
|
|
164
|
+
headers: { accept: "application/json", ...(init.headers ?? {}) },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
const timeout = error instanceof Error &&
|
|
169
|
+
(error.name === "TimeoutError" || error.name === "AbortError");
|
|
170
|
+
return {
|
|
171
|
+
kind: "failed",
|
|
172
|
+
problem: timeout
|
|
173
|
+
? `${url} didn't answer within ${MCP_OAUTH_TIMEOUT_MS / 1000} s`
|
|
174
|
+
: `${url} couldn't be fetched (${error instanceof Error ? error.message : String(error)})`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (response.type === "opaqueredirect" ||
|
|
178
|
+
(response.status >= 300 && response.status < 400)) {
|
|
179
|
+
return {
|
|
180
|
+
kind: "failed",
|
|
181
|
+
problem: `${url} answered with a redirect (${response.status || "3xx"}); discovery follows none`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
await response.body?.cancel().catch(() => { });
|
|
186
|
+
return { kind: "missing", status: response.status };
|
|
187
|
+
}
|
|
188
|
+
const text = await readBounded(response);
|
|
189
|
+
if (text === null) {
|
|
190
|
+
return {
|
|
191
|
+
kind: "failed",
|
|
192
|
+
problem: `${url} answered with more than ${MCP_OAUTH_MAX_BODY / 1024} KiB`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
let body;
|
|
196
|
+
try {
|
|
197
|
+
body = JSON.parse(text);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return { kind: "failed", problem: `${url} didn't answer with JSON` };
|
|
201
|
+
}
|
|
202
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
203
|
+
return {
|
|
204
|
+
kind: "failed",
|
|
205
|
+
problem: `${url} didn't answer with a JSON object`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return { kind: "json", body: body };
|
|
209
|
+
}
|
|
210
|
+
function stringList(value) {
|
|
211
|
+
return Array.isArray(value)
|
|
212
|
+
? value.filter((item) => typeof item === "string")
|
|
213
|
+
: [];
|
|
214
|
+
}
|
|
215
|
+
/** `/.well-known/<name>` inserted before `url`'s path (none for a bare origin). */
|
|
216
|
+
function pathInserted(url, name) {
|
|
217
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
|
|
218
|
+
return `${url.origin}/.well-known/${name}${path}`;
|
|
219
|
+
}
|
|
220
|
+
/** Whether the metadata's `resource` is `mcpUrl` or a path prefix of it on the same origin. */
|
|
221
|
+
function resourceCovers(resource, mcpUrl) {
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = new URL(resource);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (parsed.origin !== mcpUrl.origin || parsed.hash)
|
|
230
|
+
return false;
|
|
231
|
+
if (parsed.search && parsed.search !== mcpUrl.search)
|
|
232
|
+
return false;
|
|
233
|
+
const base = parsed.pathname;
|
|
234
|
+
const path = mcpUrl.pathname;
|
|
235
|
+
if (base === path || base === "/" || base === "")
|
|
236
|
+
return true;
|
|
237
|
+
const trimmed = base.replace(/\/+$/, "");
|
|
238
|
+
return (path === trimmed || path.startsWith(`${trimmed}/`) || path === `${trimmed}/`);
|
|
239
|
+
}
|
|
240
|
+
function sameIssuer(a, b) {
|
|
241
|
+
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
|
242
|
+
}
|
|
243
|
+
/** The issuer's metadata URLs, in the MCP spec's order. */
|
|
244
|
+
function metadataUrls(issuer) {
|
|
245
|
+
const path = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/+$/, "");
|
|
246
|
+
if (!path) {
|
|
247
|
+
return [
|
|
248
|
+
`${issuer.origin}/.well-known/oauth-authorization-server`,
|
|
249
|
+
`${issuer.origin}/.well-known/openid-configuration`,
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
return [
|
|
253
|
+
`${issuer.origin}/.well-known/oauth-authorization-server${path}`,
|
|
254
|
+
`${issuer.origin}/.well-known/openid-configuration${path}`,
|
|
255
|
+
`${issuer.origin}${path}/.well-known/openid-configuration`,
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
function scopeList(value) {
|
|
259
|
+
const raw = typeof value === "string" ? value.split(/\s+/) : [...(value ?? [])];
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const scope of raw) {
|
|
262
|
+
// What a provider spec's scope list takes: printable, no space or comma, bounded.
|
|
263
|
+
if (!scope ||
|
|
264
|
+
scope.length > 200 ||
|
|
265
|
+
/[\s,]/.test(scope) ||
|
|
266
|
+
/[\p{Cc}\p{Cf}]/u.test(scope) ||
|
|
267
|
+
out.includes(scope)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
out.push(scope);
|
|
271
|
+
if (out.length === 40)
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
async function authorizationServer(fetcher, issuer) {
|
|
277
|
+
const problem = mcpOAuthUrlProblem(issuer, { query: false });
|
|
278
|
+
if (problem) {
|
|
279
|
+
return { ok: false, problem: `the authorization server: ${problem}` };
|
|
280
|
+
}
|
|
281
|
+
const tried = [];
|
|
282
|
+
for (const url of metadataUrls(new URL(issuer))) {
|
|
283
|
+
tried.push(url);
|
|
284
|
+
const fetched = await fetchJson(fetcher, url);
|
|
285
|
+
if (fetched.kind === "failed")
|
|
286
|
+
return { ok: false, problem: fetched.problem };
|
|
287
|
+
if (fetched.kind === "missing")
|
|
288
|
+
continue;
|
|
289
|
+
const said = fetched.body["issuer"];
|
|
290
|
+
if (typeof said !== "string" || !sameIssuer(said, issuer)) {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
problem: `${url} names the issuer ${typeof said === "string" ? said : "(none)"}, not ${issuer}; the Gateway refuses metadata for another server`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, metadata: fetched.body, issuer: said };
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
problem: `no authorization server metadata for ${issuer} (tried ${tried.join(", ")})`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function endpointOf(metadata, field, { required, query }) {
|
|
304
|
+
const value = metadata[field];
|
|
305
|
+
if (value === undefined || value === null || value === "") {
|
|
306
|
+
return required
|
|
307
|
+
? {
|
|
308
|
+
ok: false,
|
|
309
|
+
problem: `the authorization server's metadata has no ${field}`,
|
|
310
|
+
}
|
|
311
|
+
: { ok: true };
|
|
312
|
+
}
|
|
313
|
+
if (typeof value !== "string" || value.length > 500) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
problem: `the authorization server's ${field} isn't a URL`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const problem = mcpOAuthUrlProblem(value, { query });
|
|
320
|
+
return problem
|
|
321
|
+
? { ok: false, problem: `the authorization server's ${field}: ${problem}` }
|
|
322
|
+
: { ok: true, url: value };
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Find how `url`'s MCP server takes OAuth: its protected resource, its
|
|
326
|
+
* authorization server, the endpoints, the scopes to ask for and the
|
|
327
|
+
* client authentication it accepts. Refuses (with the reason) anything
|
|
328
|
+
* the Gateway would not run: no PKCE S256, a resource that isn't this
|
|
329
|
+
* server, metadata naming another issuer, an endpoint off https or on a
|
|
330
|
+
* private host, a redirect anywhere.
|
|
331
|
+
*/
|
|
332
|
+
export async function discoverMcpOAuth(input) {
|
|
333
|
+
const urlProblem = mcpOAuthUrlProblem(input.url);
|
|
334
|
+
if (urlProblem)
|
|
335
|
+
return { ok: false, problem: urlProblem };
|
|
336
|
+
const mcpUrl = new URL(input.url);
|
|
337
|
+
const candidates = input.resourceMetadataUrl
|
|
338
|
+
? [input.resourceMetadataUrl]
|
|
339
|
+
: [
|
|
340
|
+
...(mcpUrl.pathname === "/" || mcpUrl.pathname === ""
|
|
341
|
+
? []
|
|
342
|
+
: [pathInserted(mcpUrl, "oauth-protected-resource")]),
|
|
343
|
+
`${mcpUrl.origin}/.well-known/oauth-protected-resource`,
|
|
344
|
+
];
|
|
345
|
+
let resourceMetadata = null;
|
|
346
|
+
let resourceMetadataAt = "";
|
|
347
|
+
for (const candidate of candidates) {
|
|
348
|
+
const fetched = await fetchJson(input.fetch, candidate);
|
|
349
|
+
if (fetched.kind === "failed") {
|
|
350
|
+
return { ok: false, problem: fetched.problem };
|
|
351
|
+
}
|
|
352
|
+
if (fetched.kind === "missing")
|
|
353
|
+
continue;
|
|
354
|
+
resourceMetadata = fetched.body;
|
|
355
|
+
resourceMetadataAt = candidate;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
if (!resourceMetadata && input.resourceMetadataUrl) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
problem: `the server named its resource metadata at ${input.resourceMetadataUrl}, which didn't answer`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
let issuer;
|
|
365
|
+
let resource;
|
|
366
|
+
let supported = [];
|
|
367
|
+
if (resourceMetadata) {
|
|
368
|
+
const said = resourceMetadata["resource"];
|
|
369
|
+
if (typeof said !== "string" || !resourceCovers(said, mcpUrl)) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
problem: `${resourceMetadataAt} describes ${typeof said === "string" ? said : "no resource"}, not ${input.url}; the Gateway refuses metadata for another server`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const servers = stringList(resourceMetadata["authorization_servers"]);
|
|
376
|
+
if (servers.length === 0) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
problem: `${resourceMetadataAt} names no authorization server`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
issuer = servers[0];
|
|
383
|
+
resource = said;
|
|
384
|
+
supported = stringList(resourceMetadata["scopes_supported"]);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
// The 2025-03-26 spec: the authorization server is at the MCP origin.
|
|
388
|
+
issuer = mcpUrl.origin;
|
|
389
|
+
resource = input.url;
|
|
390
|
+
}
|
|
391
|
+
const server = await authorizationServer(input.fetch, issuer);
|
|
392
|
+
if (!server.ok) {
|
|
393
|
+
return {
|
|
394
|
+
ok: false,
|
|
395
|
+
problem: resourceMetadata
|
|
396
|
+
? server.problem
|
|
397
|
+
: `${input.url} has no OAuth metadata: no protected resource metadata, and ${server.problem}`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const metadata = server.metadata;
|
|
401
|
+
const methods = stringList(metadata["code_challenge_methods_supported"]);
|
|
402
|
+
if (!methods.includes("S256")) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
problem: `${server.issuer} doesn't support PKCE with S256 (code_challenge_methods_supported), which the Gateway requires`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const authorize = endpointOf(metadata, "authorization_endpoint", {
|
|
409
|
+
required: true,
|
|
410
|
+
query: true,
|
|
411
|
+
});
|
|
412
|
+
if (!authorize.ok)
|
|
413
|
+
return authorize;
|
|
414
|
+
const token = endpointOf(metadata, "token_endpoint", {
|
|
415
|
+
required: true,
|
|
416
|
+
query: false,
|
|
417
|
+
});
|
|
418
|
+
if (!token.ok)
|
|
419
|
+
return token;
|
|
420
|
+
const registration = endpointOf(metadata, "registration_endpoint", {
|
|
421
|
+
required: false,
|
|
422
|
+
query: false,
|
|
423
|
+
});
|
|
424
|
+
if (!registration.ok)
|
|
425
|
+
return registration;
|
|
426
|
+
const revocation = endpointOf(metadata, "revocation_endpoint", {
|
|
427
|
+
required: false,
|
|
428
|
+
query: false,
|
|
429
|
+
});
|
|
430
|
+
if (!revocation.ok)
|
|
431
|
+
return revocation;
|
|
432
|
+
const authMethods = stringList(metadata["token_endpoint_auth_methods_supported"]);
|
|
433
|
+
const scopes = input.scope ? scopeList(input.scope) : scopeList(supported);
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
discovery: {
|
|
437
|
+
resource,
|
|
438
|
+
issuer: server.issuer,
|
|
439
|
+
authorizationEndpoint: authorize.url,
|
|
440
|
+
tokenEndpoint: token.url,
|
|
441
|
+
...(registration.url ? { registrationEndpoint: registration.url } : {}),
|
|
442
|
+
...(revocation.url ? { revocationEndpoint: revocation.url } : {}),
|
|
443
|
+
scopes,
|
|
444
|
+
...(scopeList(supported).length > 0
|
|
445
|
+
? { supportedScopes: scopeList(supported) }
|
|
446
|
+
: {}),
|
|
447
|
+
tokenEndpointAuthMethods: authMethods.length > 0 ? authMethods : ["client_secret_basic"],
|
|
448
|
+
resourceMetadata: resourceMetadata !== null,
|
|
449
|
+
...(metadata["client_id_metadata_document_supported"] === true
|
|
450
|
+
? { clientIdMetadataDocument: true }
|
|
451
|
+
: {}),
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** The token endpoint auth method to ask for: Basic when offered, else the form, else a public client. */
|
|
456
|
+
export function chooseTokenEndpointAuthMethod(supported) {
|
|
457
|
+
if (supported.includes("client_secret_basic"))
|
|
458
|
+
return "client_secret_basic";
|
|
459
|
+
if (supported.includes("client_secret_post"))
|
|
460
|
+
return "client_secret_post";
|
|
461
|
+
if (supported.includes("none"))
|
|
462
|
+
return "none";
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Register a client with the server's authorization server (RFC 7591
|
|
467
|
+
* dynamic client registration): the Gateway's callback as the one
|
|
468
|
+
* redirect URI, the authorization-code and refresh grants, and the token
|
|
469
|
+
* endpoint auth method {@link chooseTokenEndpointAuthMethod} picks. Reads
|
|
470
|
+
* back what the server registered: its method decides `inject`.
|
|
471
|
+
*/
|
|
472
|
+
export async function registerMcpClient(input) {
|
|
473
|
+
const endpoint = input.discovery.registrationEndpoint;
|
|
474
|
+
if (!endpoint) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
unsupported: true,
|
|
478
|
+
problem: `${input.discovery.issuer} doesn't offer dynamic client registration; register a client with it and give its id`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const method = chooseTokenEndpointAuthMethod(input.discovery.tokenEndpointAuthMethods);
|
|
482
|
+
if (!method) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
problem: `${input.discovery.issuer} takes clients by ${input.discovery.tokenEndpointAuthMethods.join(", ")} only; the Gateway's clients use client_secret_basic, client_secret_post or none`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const body = {
|
|
489
|
+
client_name: input.clientName.slice(0, 100),
|
|
490
|
+
redirect_uris: [input.redirectUri],
|
|
491
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
492
|
+
response_types: ["code"],
|
|
493
|
+
token_endpoint_auth_method: method,
|
|
494
|
+
...(input.discovery.scopes.length > 0
|
|
495
|
+
? { scope: input.discovery.scopes.join(" ") }
|
|
496
|
+
: {}),
|
|
497
|
+
};
|
|
498
|
+
const fetched = await fetchJson(input.fetch, endpoint, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { "content-type": "application/json" },
|
|
501
|
+
body: JSON.stringify(body),
|
|
502
|
+
});
|
|
503
|
+
if (fetched.kind === "failed")
|
|
504
|
+
return { ok: false, problem: fetched.problem };
|
|
505
|
+
if (fetched.kind === "missing") {
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
problem: `${endpoint} refused the registration (${fetched.status})`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const answer = fetched.body;
|
|
512
|
+
const clientId = answer["client_id"];
|
|
513
|
+
if (typeof clientId !== "string" || !clientId || clientId.length > 500) {
|
|
514
|
+
return { ok: false, problem: `${endpoint} answered without a client_id` };
|
|
515
|
+
}
|
|
516
|
+
const said = answer["token_endpoint_auth_method"];
|
|
517
|
+
const authMethod = typeof said === "string" && said ? said : method;
|
|
518
|
+
if (!["client_secret_basic", "client_secret_post", "none"].includes(authMethod)) {
|
|
519
|
+
return {
|
|
520
|
+
ok: false,
|
|
521
|
+
problem: `${endpoint} registered the client for ${authMethod}, which the Gateway doesn't use`,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const secret = answer["client_secret"];
|
|
525
|
+
const clientSecret = typeof secret === "string" && secret.length > 0 ? secret : undefined;
|
|
526
|
+
if (authMethod !== "none" && !clientSecret) {
|
|
527
|
+
return {
|
|
528
|
+
ok: false,
|
|
529
|
+
problem: `${endpoint} registered a confidential client (${authMethod}) but returned no client_secret`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
clientId,
|
|
535
|
+
...(clientSecret === undefined ? {} : { clientSecret }),
|
|
536
|
+
inject: authMethod === "client_secret_basic" ? "basic" : "form",
|
|
537
|
+
authMethod,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The client metadata document the Gateway serves at `clientId` (CIMD):
|
|
542
|
+
* a public client (`token_endpoint_auth_method: none`, so PKCE is its
|
|
543
|
+
* proof) with the Gateway's callback for the app as its one redirect URI.
|
|
544
|
+
*/
|
|
545
|
+
export function mcpClientMetadataDocument(input) {
|
|
546
|
+
return {
|
|
547
|
+
client_id: input.clientId,
|
|
548
|
+
client_name: input.clientName.slice(0, 100),
|
|
549
|
+
client_uri: input.clientUri,
|
|
550
|
+
redirect_uris: [input.redirectUri],
|
|
551
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
552
|
+
response_types: ["code"],
|
|
553
|
+
token_endpoint_auth_method: "none",
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* A client for the Gateway at the server: the one it registers (RFC 7591),
|
|
558
|
+
* else, when it registers none, the Gateway's client metadata document's
|
|
559
|
+
* URL (`documentUrl`, null for a Gateway not on https) where the server
|
|
560
|
+
* takes CIMD and public clients. `unsupported` when neither can be had: a
|
|
561
|
+
* client must be registered by hand and pasted.
|
|
562
|
+
*/
|
|
563
|
+
export async function obtainMcpClient(input) {
|
|
564
|
+
const { discovery } = input;
|
|
565
|
+
if (discovery.registrationEndpoint || !discovery.clientIdMetadataDocument) {
|
|
566
|
+
const registered = await registerMcpClient(input);
|
|
567
|
+
return registered.ok ? { ...registered, via: "dcr" } : registered;
|
|
568
|
+
}
|
|
569
|
+
if (!input.documentUrl) {
|
|
570
|
+
return {
|
|
571
|
+
ok: false,
|
|
572
|
+
unsupported: true,
|
|
573
|
+
problem: `${discovery.issuer} takes clients by metadata document only, which the Gateway serves on https alone; register a client with it and give its id`,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (!discovery.tokenEndpointAuthMethods.includes("none")) {
|
|
577
|
+
return {
|
|
578
|
+
ok: false,
|
|
579
|
+
unsupported: true,
|
|
580
|
+
problem: `${discovery.issuer} takes clients by metadata document but no public clients (token_endpoint_auth_methods_supported has no "none"); register a client with it and give its id`,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
ok: true,
|
|
585
|
+
clientId: input.documentUrl,
|
|
586
|
+
inject: "form",
|
|
587
|
+
authMethod: "none",
|
|
588
|
+
via: "cimd",
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
function specName(name, suffix) {
|
|
592
|
+
const room = 64 - suffix.length;
|
|
593
|
+
const trimmed = name.trim() || "MCP";
|
|
594
|
+
return `${trimmed.length > room ? trimmed.slice(0, room).trimEnd() : trimmed}${suffix}`;
|
|
595
|
+
}
|
|
596
|
+
function checked(id, spec) {
|
|
597
|
+
const parsed = parseProviderSpec(spec);
|
|
598
|
+
if (!parsed.ok) {
|
|
599
|
+
return { ok: false, problem: describeSpecProblems(parsed.problems) };
|
|
600
|
+
}
|
|
601
|
+
return { ok: true, id, spec };
|
|
602
|
+
}
|
|
603
|
+
function hostAndOrigin(url) {
|
|
604
|
+
let parsed;
|
|
605
|
+
try {
|
|
606
|
+
parsed = new URL(url);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return { ok: false, problem: `${url} isn't a URL` };
|
|
610
|
+
}
|
|
611
|
+
if (parsed.protocol !== "https:") {
|
|
612
|
+
return {
|
|
613
|
+
ok: false,
|
|
614
|
+
problem: `${url} isn't https: a credential goes to an https server only`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
ok: true,
|
|
619
|
+
host: parsed.hostname.toLowerCase().replace(/\.$/, ""),
|
|
620
|
+
origin: parsed.origin,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* The `oauth2-user` spec of an app's own OAuth row (`<appId>-mcp`): the
|
|
625
|
+
* MCP URL's host as its one API host (the token goes there and nowhere
|
|
626
|
+
* else), the discovered endpoints, the client in `<APPID>_MCP_CLIENT_ID`
|
|
627
|
+
* (and `_SECRET` for a confidential client), PKCE, the `resource` and
|
|
628
|
+
* `issuer`. The scopes the connect asks for are the app's (its manifest's
|
|
629
|
+
* entry, from discovery); the ones the server says it supports are
|
|
630
|
+
* tolerated on a token.
|
|
631
|
+
*/
|
|
632
|
+
export function mcpOAuthProviderSpec(input) {
|
|
633
|
+
const where = hostAndOrigin(input.url);
|
|
634
|
+
if (!where.ok)
|
|
635
|
+
return where;
|
|
636
|
+
const id = remoteMcpProviderId(input.appId);
|
|
637
|
+
// Bounded as a spec's scope list is (40).
|
|
638
|
+
const tolerated = [
|
|
639
|
+
...new Set([
|
|
640
|
+
...input.discovery.scopes,
|
|
641
|
+
...(input.discovery.supportedScopes ?? []),
|
|
642
|
+
]),
|
|
643
|
+
].slice(0, 40);
|
|
644
|
+
const spec = {
|
|
645
|
+
id,
|
|
646
|
+
name: specName(input.name || input.appId, " MCP server"),
|
|
647
|
+
docs: where.origin,
|
|
648
|
+
hosts: [where.host],
|
|
649
|
+
auth: {
|
|
650
|
+
kind: "oauth2-user",
|
|
651
|
+
authorizeUrl: input.discovery.authorizationEndpoint,
|
|
652
|
+
tokenUrl: input.discovery.tokenEndpoint,
|
|
653
|
+
...(input.discovery.revocationEndpoint
|
|
654
|
+
? { revokeUrl: input.discovery.revocationEndpoint }
|
|
655
|
+
: {}),
|
|
656
|
+
clientId: remoteMcpClientIdSetting(input.appId),
|
|
657
|
+
...(input.client.hasSecret
|
|
658
|
+
? { clientSecret: remoteMcpClientSecretSetting(input.appId) }
|
|
659
|
+
: {}),
|
|
660
|
+
inject: input.client.inject,
|
|
661
|
+
pkce: true,
|
|
662
|
+
scopeSeparator: "space",
|
|
663
|
+
resource: input.discovery.resource,
|
|
664
|
+
issuer: input.discovery.issuer,
|
|
665
|
+
...(tolerated.length > 0 ? { tolerated } : {}),
|
|
666
|
+
},
|
|
667
|
+
tags: ["mcp"],
|
|
668
|
+
};
|
|
669
|
+
return checked(id, spec);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The API-key spec of an app's own static credential (`<appId>-mcp`): the
|
|
673
|
+
* MCP URL's host as its one host, the value in `<APPID>_MCP_TOKEN`, placed
|
|
674
|
+
* as `kind` says: `Authorization: Bearer` (or `<prefix>` as the scheme),
|
|
675
|
+
* a header (`header`, `prefix`), a query parameter (`param`), or HTTP
|
|
676
|
+
* Basic with the value as `user:password`.
|
|
677
|
+
*/
|
|
678
|
+
export function mcpStaticProviderSpec(input) {
|
|
679
|
+
const where = hostAndOrigin(input.url);
|
|
680
|
+
if (!where.ok)
|
|
681
|
+
return where;
|
|
682
|
+
const id = remoteMcpProviderId(input.appId);
|
|
683
|
+
const secret = remoteMcpTokenSetting(input.appId);
|
|
684
|
+
let auth;
|
|
685
|
+
switch (input.kind) {
|
|
686
|
+
case "bearer": {
|
|
687
|
+
const scheme = (input.prefix ?? "").trim();
|
|
688
|
+
auth = {
|
|
689
|
+
kind: "bearer",
|
|
690
|
+
secret,
|
|
691
|
+
...(scheme && scheme.toLowerCase() !== "bearer" ? { scheme } : {}),
|
|
692
|
+
};
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
case "header": {
|
|
696
|
+
const problem = remoteMcpHeaderProblem(input.header);
|
|
697
|
+
if (problem)
|
|
698
|
+
return { ok: false, problem };
|
|
699
|
+
auth = {
|
|
700
|
+
kind: "header",
|
|
701
|
+
secret,
|
|
702
|
+
header: input.header,
|
|
703
|
+
...(input.prefix ? { prefix: input.prefix } : {}),
|
|
704
|
+
};
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
case "query": {
|
|
708
|
+
if (!input.param) {
|
|
709
|
+
return { ok: false, problem: "name the query parameter (--param)" };
|
|
710
|
+
}
|
|
711
|
+
auth = { kind: "query", secret, param: input.param };
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
case "basic":
|
|
715
|
+
auth = { kind: "basic", secret, as: "pair" };
|
|
716
|
+
break;
|
|
717
|
+
default:
|
|
718
|
+
return {
|
|
719
|
+
ok: false,
|
|
720
|
+
problem: `${String(input.kind)} isn't a static credential kind`,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
const name = input.name.trim() || input.appId;
|
|
724
|
+
const spec = {
|
|
725
|
+
id,
|
|
726
|
+
name: specName(name, " MCP server"),
|
|
727
|
+
docs: where.origin,
|
|
728
|
+
hosts: [where.host],
|
|
729
|
+
auth,
|
|
730
|
+
data: "per-call",
|
|
731
|
+
hint: `The credential ${name}'s MCP server takes${input.kind === "basic" ? ", as user:password" : ""}. The Gateway sends it to ${where.host} only.`.slice(0, 500),
|
|
732
|
+
tags: ["mcp"],
|
|
733
|
+
};
|
|
734
|
+
return checked(id, spec);
|
|
735
|
+
}
|