tiny-http-mcp-server 0.1.20 → 0.1.22
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/dist/composition.json +1 -1
- package/node_modules/mcp-oauth/README.md +22 -1
- package/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +27 -13
- package/node_modules/mcp-oauth/dist/client/loopback-authorization.d.ts +5 -0
- package/node_modules/mcp-oauth/dist/client/loopback-authorization.js +134 -72
- package/node_modules/mcp-oauth/dist/client/token-endpoint.d.ts +2 -0
- package/node_modules/mcp-oauth/dist/client/token-endpoint.js +5 -1
- package/node_modules/mcp-oauth/dist/client/types.d.ts +9 -1
- package/node_modules/tiny-mcp-client/README.md +5 -1
- package/node_modules/tiny-mcp-client/dist/index.d.ts +18 -3
- package/node_modules/tiny-mcp-client/dist/index.js +243 -117
- package/package.json +1 -1
package/dist/composition.json
CHANGED
|
@@ -40,10 +40,14 @@ const verifier = createJwksTokenVerifier({
|
|
|
40
40
|
- `client`
|
|
41
41
|
- `mode: "dynamic"` with optional `metadata`
|
|
42
42
|
- `mode: "static"` with `clientId`, optional `clientSecret`, optional `metadata`
|
|
43
|
-
- `
|
|
43
|
+
- `allowInteractive: false` prevents interactive login while retaining cached tokens and silent refresh
|
|
44
|
+
- `browser.openBrowser(url)` optional
|
|
44
45
|
- `browser.readLine()` optional
|
|
45
46
|
- `browser.createServer()` optional
|
|
46
47
|
- `browser.landingPage` optional
|
|
48
|
+
- `browser.redirectUri` optional exact registered HTTP loopback callback with a fixed port
|
|
49
|
+
- `browser.signal` optional cancellation signal
|
|
50
|
+
- `browser.timeoutMs` optional authorization deadline (default 120,000 ms)
|
|
47
51
|
- `sessionStore` optional
|
|
48
52
|
- `authStore` optional `auth-store` backend config for the default session store
|
|
49
53
|
- `now()` optional clock override
|
|
@@ -62,6 +66,23 @@ const verifier = createJwksTokenVerifier({
|
|
|
62
66
|
| `requireAccessTokenType` | `boolean` | `false` | Require the JWT `typ` protected header to be `at+jwt`. |
|
|
63
67
|
| `fetch` | `typeof fetch` | global `fetch` | Custom fetch implementation. |
|
|
64
68
|
|
|
69
|
+
Fixed redirects support `localhost`, `127.0.0.1`, and `::1` over HTTP. Their
|
|
70
|
+
exact spelling, port, path and query are preserved through registration,
|
|
71
|
+
authorization and code exchange. Credentials, fragments, port zero and reserved
|
|
72
|
+
OAuth callback query parameters are rejected before binding a listener. Omit
|
|
73
|
+
`redirectUri` to allocate a random loopback port. Standalone callback sessions
|
|
74
|
+
accept the same `redirectUri`, `signal` and `timeoutMs` options. Cancellation,
|
|
75
|
+
timeout and explicit close settle pending code waits and release listeners.
|
|
76
|
+
Always close a successful standalone session in `finally`.
|
|
77
|
+
|
|
78
|
+
Provider request inputs accept an optional `signal`. It reaches callback waits,
|
|
79
|
+
registration, token requests and bounded token-body reads. Cancellation retains
|
|
80
|
+
its original reason and does not retry authorization. Custom providers should
|
|
81
|
+
observe the supplied signal and pass it to any work they start.
|
|
82
|
+
|
|
83
|
+
Configure `client.metadata.scope` to request a precise scope set; broader
|
|
84
|
+
discovery metadata does not override it.
|
|
85
|
+
|
|
65
86
|
`createAuthStoreSessionStore(options)` accepts the standard `auth-store` config.
|
|
66
87
|
|
|
67
88
|
## Environment Variables
|
|
@@ -25,7 +25,7 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
25
25
|
async authorizeRequest(input) {
|
|
26
26
|
assertNoAccessTokenInUrl(input.requestUrl, "Protected resource request URL");
|
|
27
27
|
const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
|
|
28
|
-
const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false);
|
|
28
|
+
const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false, false, input.signal);
|
|
29
29
|
const accessToken = session?.tokens?.accessToken;
|
|
30
30
|
if (session === null ||
|
|
31
31
|
accessToken === undefined ||
|
|
@@ -47,13 +47,14 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
47
47
|
const session = await ensureAuthorizedSession(resource, {
|
|
48
48
|
...input.discovery,
|
|
49
49
|
resource
|
|
50
|
-
}, input.fetch, true, forceRefresh);
|
|
50
|
+
}, input.fetch, true, forceRefresh, input.signal);
|
|
51
51
|
if (session?.tokens?.accessToken === undefined) {
|
|
52
52
|
return { action: "fail" };
|
|
53
53
|
}
|
|
54
54
|
return { action: "retry" };
|
|
55
55
|
}
|
|
56
56
|
catch (error) {
|
|
57
|
+
input.signal?.throwIfAborted();
|
|
57
58
|
return {
|
|
58
59
|
action: "fail",
|
|
59
60
|
error: error instanceof Error ? error : new Error(String(error))
|
|
@@ -61,9 +62,11 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
};
|
|
64
|
-
async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false) {
|
|
65
|
+
async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
|
|
66
|
+
signal?.throwIfAborted();
|
|
65
67
|
const canonicalResource = canonicalizeResourceIndicator(resource);
|
|
66
68
|
let session = await loadSession(canonicalResource);
|
|
69
|
+
signal?.throwIfAborted();
|
|
67
70
|
if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
|
|
68
71
|
throw new Error("OAuth discovery authorization-server issuer mismatch");
|
|
69
72
|
}
|
|
@@ -80,7 +83,7 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
80
83
|
if (session?.tokens?.refreshToken !== undefined &&
|
|
81
84
|
sessionDiscovery !== undefined &&
|
|
82
85
|
(forceRefresh || isExpired(session.tokens, now))) {
|
|
83
|
-
session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch);
|
|
86
|
+
session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
|
|
84
87
|
if (session?.tokens !== undefined && !isExpired(session.tokens, now)) {
|
|
85
88
|
return session;
|
|
86
89
|
}
|
|
@@ -92,9 +95,12 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
92
95
|
if (!allowInteractive || sessionDiscovery === undefined) {
|
|
93
96
|
return session;
|
|
94
97
|
}
|
|
95
|
-
|
|
98
|
+
if (options.allowInteractive === false)
|
|
99
|
+
throw new Error("OAuth interactive authorization is disabled");
|
|
100
|
+
return authorizeSession(canonicalResource, session, sessionDiscovery, fetch, signal);
|
|
96
101
|
}
|
|
97
|
-
async function refreshSession(resource, session, discovery, fetch) {
|
|
102
|
+
async function refreshSession(resource, session, discovery, fetch, signal) {
|
|
103
|
+
signal?.throwIfAborted();
|
|
98
104
|
assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
|
|
99
105
|
const inFlight = refreshPromises.get(resource);
|
|
100
106
|
if (inFlight !== undefined) {
|
|
@@ -115,12 +121,13 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
115
121
|
clientSecret: session.client.clientSecret,
|
|
116
122
|
refreshToken: session.tokens.refreshToken,
|
|
117
123
|
resource,
|
|
118
|
-
fetch,
|
|
124
|
+
fetch, signal,
|
|
119
125
|
now
|
|
120
126
|
});
|
|
121
127
|
break;
|
|
122
128
|
}
|
|
123
129
|
catch (error) {
|
|
130
|
+
signal?.throwIfAborted();
|
|
124
131
|
if (error instanceof OAuthError && error.error === "invalid_grant") {
|
|
125
132
|
const clearedSession = clearSessionTokens(session);
|
|
126
133
|
await saveSession(resource, clearedSession);
|
|
@@ -156,7 +163,8 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
156
163
|
refreshPromises.set(resource, promise);
|
|
157
164
|
return promise;
|
|
158
165
|
}
|
|
159
|
-
async function authorizeSession(resource, existingSession, discovery, fetch) {
|
|
166
|
+
async function authorizeSession(resource, existingSession, discovery, fetch, signal) {
|
|
167
|
+
signal?.throwIfAborted();
|
|
160
168
|
const inFlight = authorizationPromises.get(resource);
|
|
161
169
|
if (inFlight !== undefined) {
|
|
162
170
|
return inFlight;
|
|
@@ -172,11 +180,14 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
172
180
|
openBrowser: options.browser.openBrowser,
|
|
173
181
|
readLine: options.browser.readLine,
|
|
174
182
|
createServer: options.browser.createServer,
|
|
175
|
-
landingPage: options.browser.landingPage
|
|
183
|
+
landingPage: options.browser.landingPage,
|
|
184
|
+
redirectUri: options.browser.redirectUri,
|
|
185
|
+
signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
|
|
186
|
+
timeoutMs: options.browser.timeoutMs
|
|
176
187
|
});
|
|
177
188
|
let resolvedClient = null;
|
|
178
189
|
try {
|
|
179
|
-
resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch);
|
|
190
|
+
resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
|
|
180
191
|
const sessionWithoutTokens = {
|
|
181
192
|
resource,
|
|
182
193
|
authorizationServer: discovery.authorizationServer,
|
|
@@ -203,7 +214,7 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
203
214
|
codeVerifier: verifier,
|
|
204
215
|
redirectUri: loopback.redirectUri,
|
|
205
216
|
resource,
|
|
206
|
-
fetch,
|
|
217
|
+
fetch, signal,
|
|
207
218
|
now
|
|
208
219
|
});
|
|
209
220
|
const session = {
|
|
@@ -214,6 +225,7 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
214
225
|
return session;
|
|
215
226
|
}
|
|
216
227
|
catch (error) {
|
|
228
|
+
signal?.throwIfAborted();
|
|
217
229
|
if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
|
|
218
230
|
reRegistrationAttempted = true;
|
|
219
231
|
await clearRegisteredClient(discovery.authorizationServer);
|
|
@@ -240,7 +252,8 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
240
252
|
authorizationPromises.set(resource, finalPromise);
|
|
241
253
|
return finalPromise;
|
|
242
254
|
}
|
|
243
|
-
async function resolveClient(existingSession, discovery, redirectUri, fetch) {
|
|
255
|
+
async function resolveClient(existingSession, discovery, redirectUri, fetch, parentSignal) {
|
|
256
|
+
parentSignal?.throwIfAborted();
|
|
244
257
|
const configuredClient = normalizeConfiguredClient(options.client);
|
|
245
258
|
if (options.client.mode === "static") {
|
|
246
259
|
if (configuredClient === null) {
|
|
@@ -292,7 +305,8 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
292
305
|
}
|
|
293
306
|
}
|
|
294
307
|
const registrationBody = buildClientRegistrationBody(getClientMetadata(options.client), redirectUri);
|
|
295
|
-
const
|
|
308
|
+
const deadline = AbortSignal.timeout(30_000);
|
|
309
|
+
const signal = parentSignal === undefined ? deadline : AbortSignal.any([parentSignal, deadline]);
|
|
296
310
|
const response = await fetchMcpResponse(fetch, registrationEndpoint, {
|
|
297
311
|
method: "POST",
|
|
298
312
|
headers: {
|
|
@@ -9,6 +9,11 @@ export interface LoopbackAuthorizationOptions {
|
|
|
9
9
|
createServer?: () => http.Server;
|
|
10
10
|
landingPage?: OAuthLandingPage;
|
|
11
11
|
callbackPath?: string;
|
|
12
|
+
/** Exact registered HTTP loopback redirect, including its fixed port and query. */
|
|
13
|
+
redirectUri?: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
/** Bounds listener setup and authorization; defaults to two minutes. */
|
|
16
|
+
timeoutMs?: number;
|
|
12
17
|
}
|
|
13
18
|
export interface LoopbackAuthorizationSession {
|
|
14
19
|
redirectUri: string;
|
|
@@ -1,77 +1,150 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { parseAuthorizationState } from "./authorization-state.js";
|
|
3
3
|
export async function createLoopbackAuthorizationSession(options = {}) {
|
|
4
|
-
|
|
4
|
+
options.signal?.throwIfAborted();
|
|
5
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
6
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647)
|
|
7
|
+
throw new Error("OAuth authorization timeoutMs must be a positive supported timer interval");
|
|
8
|
+
const target = loopbackTarget(options);
|
|
5
9
|
const server = options.createServer ? options.createServer() : http.createServer();
|
|
6
|
-
const
|
|
7
|
-
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
let closed = false;
|
|
12
|
+
let used = false;
|
|
13
|
+
const callerAbort = () => controller.abort(options.signal?.reason);
|
|
14
|
+
const teardown = () => {
|
|
15
|
+
if (closed)
|
|
16
|
+
return;
|
|
17
|
+
closed = true;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
options.signal?.removeEventListener("abort", callerAbort);
|
|
20
|
+
server.closeAllConnections?.();
|
|
21
|
+
server.close();
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => controller.abort(new Error("OAuth authorization timed out")), timeoutMs);
|
|
24
|
+
timer.unref?.();
|
|
25
|
+
controller.signal.addEventListener("abort", teardown, { once: true });
|
|
26
|
+
options.signal?.addEventListener("abort", callerAbort, { once: true });
|
|
27
|
+
if (options.signal?.aborted)
|
|
28
|
+
callerAbort();
|
|
29
|
+
let port;
|
|
30
|
+
try {
|
|
31
|
+
port = await startServer(server, target.port, target.host, controller.signal);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
controller.abort(error);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
const redirectUri = options.redirectUri ?? `http://127.0.0.1:${port}${target.callbackPath}`;
|
|
8
38
|
return {
|
|
9
39
|
redirectUri,
|
|
10
40
|
async waitForCode(authorizationUrl) {
|
|
11
|
-
|
|
41
|
+
controller.signal.throwIfAborted();
|
|
42
|
+
if (used)
|
|
43
|
+
throw new Error("OAuth authorization session has already been used");
|
|
44
|
+
used = true;
|
|
45
|
+
try {
|
|
46
|
+
return await waitForAuthorizationCode(server, authorizationUrl, options, target.callbackPath, controller.signal);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
12
51
|
},
|
|
13
52
|
close() {
|
|
14
|
-
|
|
15
|
-
server.close();
|
|
53
|
+
controller.abort(new Error("OAuth authorization session closed"));
|
|
16
54
|
}
|
|
17
55
|
};
|
|
18
56
|
}
|
|
19
|
-
|
|
57
|
+
function loopbackTarget(options) {
|
|
58
|
+
if (options.redirectUri !== undefined) {
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(options.redirectUri);
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
throw new Error("Invalid OAuth loopback redirect URI", { cause });
|
|
65
|
+
}
|
|
66
|
+
const forbiddenQuery = ["code", "state", "error", "error_description", "iss"];
|
|
67
|
+
if (url.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
|
68
|
+
|| url.username || url.password || url.hash || url.port === "0"
|
|
69
|
+
|| forbiddenQuery.some(name => url.searchParams.has(name))
|
|
70
|
+
|| [...options.redirectUri].some(char => char.codePointAt(0) <= 32)
|
|
71
|
+
|| (options.callbackPath !== undefined && options.callbackPath !== url.pathname))
|
|
72
|
+
throw new Error("Invalid OAuth loopback redirect URI");
|
|
73
|
+
return { port: url.port ? Number(url.port) : 80, host: url.hostname === "[::1]" ? "::1" : url.hostname, callbackPath: url.pathname };
|
|
74
|
+
}
|
|
75
|
+
const callbackPath = options.callbackPath ?? "/callback";
|
|
76
|
+
const parsed = new URL(callbackPath, "http://127.0.0.1");
|
|
77
|
+
if (!callbackPath.startsWith("/") || parsed.origin !== "http://127.0.0.1" || parsed.pathname !== callbackPath || parsed.search || parsed.hash)
|
|
78
|
+
throw new Error("Invalid OAuth loopback callback path");
|
|
79
|
+
return { port: 0, host: "127.0.0.1", callbackPath };
|
|
80
|
+
}
|
|
81
|
+
async function startServer(server, port, host, signal) {
|
|
82
|
+
signal.throwIfAborted();
|
|
20
83
|
return new Promise((resolve, reject) => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
84
|
+
const cleanup = () => { server.off("error", handleError); signal.removeEventListener("abort", aborted); };
|
|
85
|
+
const handleError = (error) => { cleanup(); reject(error); };
|
|
86
|
+
const aborted = () => { cleanup(); reject(signal.reason); };
|
|
25
87
|
server.once("error", handleError);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
88
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
89
|
+
try {
|
|
90
|
+
server.listen(port, host, () => {
|
|
91
|
+
cleanup();
|
|
92
|
+
if (signal.aborted) {
|
|
93
|
+
server.close();
|
|
94
|
+
reject(signal.reason);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const address = server.address();
|
|
98
|
+
if (address === null || typeof address === "string") {
|
|
99
|
+
reject(new Error("OAuth listener has no TCP address"));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
resolve(address.port);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
cleanup();
|
|
107
|
+
reject(error);
|
|
108
|
+
}
|
|
31
109
|
});
|
|
32
110
|
}
|
|
33
|
-
function waitForAuthorizationCode(server, authorizationUrl, options, callbackPath) {
|
|
111
|
+
function waitForAuthorizationCode(server, authorizationUrl, options, callbackPath, signal) {
|
|
112
|
+
signal.throwIfAborted();
|
|
34
113
|
const expectedAuthorization = readExpectedAuthorizationCallback(authorizationUrl);
|
|
35
114
|
return new Promise((resolve, reject) => {
|
|
36
115
|
let settled = false;
|
|
37
116
|
const settle = (fn) => {
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
server.off("request", request);
|
|
121
|
+
signal.removeEventListener("abort", aborted);
|
|
122
|
+
fn();
|
|
42
123
|
};
|
|
43
|
-
|
|
44
|
-
|
|
124
|
+
const aborted = () => settle(() => reject(signal.reason));
|
|
125
|
+
const request = (req, res) => {
|
|
126
|
+
let url;
|
|
127
|
+
try {
|
|
128
|
+
url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
res.writeHead(400);
|
|
132
|
+
res.end("Invalid callback URL");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
45
135
|
if (url.pathname !== callbackPath) {
|
|
46
136
|
res.writeHead(404);
|
|
47
137
|
res.end("Not found");
|
|
48
138
|
return;
|
|
49
139
|
}
|
|
50
140
|
const callbackParameters = {
|
|
51
|
-
code: url.searchParams.get("code"),
|
|
52
|
-
|
|
53
|
-
errorDescription: url.searchParams.get("error_description"),
|
|
54
|
-
state: url.searchParams.get("state"),
|
|
55
|
-
iss: url.searchParams.get("iss")
|
|
141
|
+
code: url.searchParams.get("code"), error: url.searchParams.get("error"),
|
|
142
|
+
errorDescription: url.searchParams.get("error_description"), state: url.searchParams.get("state"), iss: url.searchParams.get("iss")
|
|
56
143
|
};
|
|
57
144
|
try {
|
|
58
145
|
validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
res.writeHead(400);
|
|
62
|
-
res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
|
|
63
|
-
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
const authorizationError = callbackParameters.error;
|
|
67
|
-
if (authorizationError !== null) {
|
|
68
|
-
const description = callbackParameters.errorDescription ?? authorizationError;
|
|
69
|
-
res.writeHead(400);
|
|
70
|
-
res.end(`Authorization failed: ${description}`);
|
|
71
|
-
settle(() => reject(createAuthorizationError(authorizationError, description)));
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
try {
|
|
146
|
+
if (callbackParameters.error !== null)
|
|
147
|
+
throw createAuthorizationError(callbackParameters.error, callbackParameters.errorDescription ?? callbackParameters.error);
|
|
75
148
|
const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
|
|
76
149
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
77
150
|
res.end(buildSuccessPage(options.landingPage));
|
|
@@ -80,39 +153,28 @@ function waitForAuthorizationCode(server, authorizationUrl, options, callbackPat
|
|
|
80
153
|
catch (error) {
|
|
81
154
|
res.writeHead(400);
|
|
82
155
|
res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
|
|
83
|
-
settle(() => reject(error
|
|
156
|
+
settle(() => reject(error));
|
|
84
157
|
}
|
|
85
|
-
}
|
|
158
|
+
};
|
|
159
|
+
server.on("request", request);
|
|
160
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
86
161
|
if (options.readLine !== undefined) {
|
|
87
|
-
options
|
|
88
|
-
|
|
89
|
-
.then((input) => {
|
|
90
|
-
const callbackParameters = extractCallbackParametersFromInput(input);
|
|
91
|
-
if (callbackParameters === null) {
|
|
92
|
-
settle(() => reject(new Error("OAuth callback missing authorization code")));
|
|
162
|
+
void Promise.resolve().then(() => settled ? undefined : options.readLine()).then(input => {
|
|
163
|
+
if (settled)
|
|
93
164
|
return;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
106
|
-
}
|
|
107
|
-
})
|
|
108
|
-
.catch((error) => {
|
|
109
|
-
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
110
|
-
});
|
|
165
|
+
const callbackParameters = extractCallbackParametersFromInput(input);
|
|
166
|
+
if (callbackParameters === null)
|
|
167
|
+
throw new Error("OAuth callback missing authorization code");
|
|
168
|
+
validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
|
|
169
|
+
if (callbackParameters.error !== null)
|
|
170
|
+
throw createAuthorizationError(callbackParameters.error, callbackParameters.errorDescription ?? callbackParameters.error);
|
|
171
|
+
const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
|
|
172
|
+
settle(() => resolve(code));
|
|
173
|
+
}).catch(error => settle(() => reject(error)));
|
|
111
174
|
}
|
|
112
175
|
if (options.openBrowser !== undefined) {
|
|
113
|
-
|
|
114
|
-
settle(() => reject(error));
|
|
115
|
-
});
|
|
176
|
+
void Promise.resolve().then(() => settled ? undefined : options.openBrowser(authorizationUrl))
|
|
177
|
+
.catch(error => settle(() => reject(error)));
|
|
116
178
|
}
|
|
117
179
|
});
|
|
118
180
|
}
|
|
@@ -25,6 +25,7 @@ export declare function exchangeAuthorizationCode(input: {
|
|
|
25
25
|
redirectUri: string;
|
|
26
26
|
resource: string;
|
|
27
27
|
fetch: OAuthMetadataFetch;
|
|
28
|
+
signal?: AbortSignal;
|
|
28
29
|
now: () => number;
|
|
29
30
|
}): Promise<StoredOAuthTokens>;
|
|
30
31
|
export declare function refreshAccessToken(input: {
|
|
@@ -34,6 +35,7 @@ export declare function refreshAccessToken(input: {
|
|
|
34
35
|
refreshToken: string;
|
|
35
36
|
resource: string;
|
|
36
37
|
fetch: OAuthMetadataFetch;
|
|
38
|
+
signal?: AbortSignal;
|
|
37
39
|
now: () => number;
|
|
38
40
|
}): Promise<StoredOAuthTokens>;
|
|
39
41
|
export declare function readOAuthJsonObjectResponse(response: Response, signal?: AbortSignal): Promise<Record<string, unknown>>;
|
|
@@ -44,6 +44,7 @@ export async function exchangeAuthorizationCode(input) {
|
|
|
44
44
|
resource
|
|
45
45
|
},
|
|
46
46
|
fetch: input.fetch,
|
|
47
|
+
signal: input.signal,
|
|
47
48
|
now: input.now
|
|
48
49
|
});
|
|
49
50
|
}
|
|
@@ -59,6 +60,7 @@ export async function refreshAccessToken(input) {
|
|
|
59
60
|
resource
|
|
60
61
|
},
|
|
61
62
|
fetch: input.fetch,
|
|
63
|
+
signal: input.signal,
|
|
62
64
|
now: input.now
|
|
63
65
|
});
|
|
64
66
|
}
|
|
@@ -70,7 +72,9 @@ async function requestTokens(input) {
|
|
|
70
72
|
if (input.clientSecret !== undefined) {
|
|
71
73
|
body.set("client_secret", input.clientSecret);
|
|
72
74
|
}
|
|
73
|
-
|
|
75
|
+
input.signal?.throwIfAborted();
|
|
76
|
+
const deadline = AbortSignal.timeout(30_000);
|
|
77
|
+
const signal = input.signal === undefined ? deadline : AbortSignal.any([input.signal, deadline]);
|
|
74
78
|
const response = await fetchMcpResponse(input.fetch, input.tokenEndpoint, {
|
|
75
79
|
method: "POST",
|
|
76
80
|
headers: {
|
|
@@ -32,6 +32,7 @@ export interface OAuthClientProvider {
|
|
|
32
32
|
requestUrl: URL;
|
|
33
33
|
headers: Headers;
|
|
34
34
|
fetch: OAuthMetadataFetch;
|
|
35
|
+
signal?: AbortSignal;
|
|
35
36
|
}): Promise<void> | void;
|
|
36
37
|
handleUnauthorized(input: {
|
|
37
38
|
requestUrl: URL;
|
|
@@ -39,6 +40,7 @@ export interface OAuthClientProvider {
|
|
|
39
40
|
challenge: OAuthUnauthorizedChallenge | null;
|
|
40
41
|
discovery: OAuthDiscoveryResult;
|
|
41
42
|
fetch: OAuthMetadataFetch;
|
|
43
|
+
signal?: AbortSignal;
|
|
42
44
|
}): Promise<{
|
|
43
45
|
action: "retry";
|
|
44
46
|
} | {
|
|
@@ -95,8 +97,14 @@ export interface DefaultOAuthClientProviderOptions {
|
|
|
95
97
|
clientSecret?: string;
|
|
96
98
|
metadata?: OAuthClientMetadata;
|
|
97
99
|
};
|
|
100
|
+
/** Disable interactive authorization while allowing cached tokens and silent refresh. */
|
|
101
|
+
allowInteractive?: boolean;
|
|
98
102
|
browser: {
|
|
99
|
-
openBrowser(url: string): Promise<void>;
|
|
103
|
+
openBrowser?(url: string): Promise<void>;
|
|
104
|
+
/** Exact registered HTTP loopback redirect URI. */
|
|
105
|
+
redirectUri?: string;
|
|
106
|
+
signal?: AbortSignal;
|
|
107
|
+
timeoutMs?: number;
|
|
100
108
|
readLine?: () => Promise<string>;
|
|
101
109
|
createServer?: () => http.Server;
|
|
102
110
|
landingPage?: {
|
|
@@ -36,6 +36,8 @@ Legacy SSE mode opens a GET stream and posts messages to the endpoint announced
|
|
|
36
36
|
by the server. Announced endpoints must remain on the original origin without
|
|
37
37
|
embedded credentials or fragments; endpoint changes close the connection.
|
|
38
38
|
Both transports accept the same headers, OAuth provider and response limits.
|
|
39
|
+
HTTP failures expose `HttpTransportError.status` and `.method`, so callers can
|
|
40
|
+
make transport decisions without parsing error messages.
|
|
39
41
|
|
|
40
42
|
## OAuth HTTP support
|
|
41
43
|
|
|
@@ -64,7 +66,9 @@ const transport = new HttpTransport({
|
|
|
64
66
|
});
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
You can also call `discoverOAuthMetadata(resourceUrl, options)` directly, or instantiate `OAuthMetadataDiscovery` with a custom `fetch` implementation and shared cache.
|
|
69
|
+
You can also call `discoverOAuthMetadata(resourceUrl, options)` directly, or instantiate `OAuthMetadataDiscovery` with a custom `fetch` implementation and shared cache. Lookup options accept `signal`; cancellation stops metadata fetches and body reads without trying another discovery candidate.
|
|
70
|
+
|
|
71
|
+
OAuth provider inputs receive the originating request's `signal`, covering header authorization and unauthorized handling. Modern request cancellation stops its OAuth work while leaving other requests usable; transport disposal cancels all pending OAuth operations. The default provider propagates this signal to callback, registration and token work. Custom providers must observe it for their own operations.
|
|
68
72
|
|
|
69
73
|
## Testing helpers
|
|
70
74
|
|
|
@@ -109,6 +109,7 @@ interface OAuthClientProvider {
|
|
|
109
109
|
requestUrl: URL;
|
|
110
110
|
headers: Headers;
|
|
111
111
|
fetch: OAuthMetadataFetch;
|
|
112
|
+
signal?: AbortSignal;
|
|
112
113
|
}): Promise<void> | void;
|
|
113
114
|
handleUnauthorized(input: {
|
|
114
115
|
requestUrl: URL;
|
|
@@ -116,6 +117,7 @@ interface OAuthClientProvider {
|
|
|
116
117
|
challenge: OAuthUnauthorizedChallenge | null;
|
|
117
118
|
discovery: OAuthDiscoveryResult;
|
|
118
119
|
fetch: OAuthMetadataFetch;
|
|
120
|
+
signal?: AbortSignal;
|
|
119
121
|
}): Promise<{
|
|
120
122
|
action: "retry";
|
|
121
123
|
} | {
|
|
@@ -172,8 +174,14 @@ interface DefaultOAuthClientProviderOptions {
|
|
|
172
174
|
clientSecret?: string;
|
|
173
175
|
metadata?: OAuthClientMetadata;
|
|
174
176
|
};
|
|
177
|
+
/** Disable interactive authorization while allowing cached tokens and silent refresh. */
|
|
178
|
+
allowInteractive?: boolean;
|
|
175
179
|
browser: {
|
|
176
|
-
openBrowser(url: string): Promise<void>;
|
|
180
|
+
openBrowser?(url: string): Promise<void>;
|
|
181
|
+
/** Exact registered HTTP loopback redirect URI. */
|
|
182
|
+
redirectUri?: string;
|
|
183
|
+
signal?: AbortSignal;
|
|
184
|
+
timeoutMs?: number;
|
|
177
185
|
readLine?: () => Promise<string>;
|
|
178
186
|
createServer?: () => http.Server;
|
|
179
187
|
landingPage?: {
|
|
@@ -202,6 +210,7 @@ interface OAuthMetadataDiscoveryOptions {
|
|
|
202
210
|
}
|
|
203
211
|
interface OAuthMetadataLookupOptions {
|
|
204
212
|
resourceMetadataUrl?: string | URL;
|
|
213
|
+
signal?: AbortSignal;
|
|
205
214
|
}
|
|
206
215
|
declare class OAuthMetadataDiscovery {
|
|
207
216
|
private readonly fetchImpl;
|
|
@@ -209,7 +218,7 @@ declare class OAuthMetadataDiscovery {
|
|
|
209
218
|
private readonly memoryCache;
|
|
210
219
|
constructor({ fetch, cache }?: OAuthMetadataDiscoveryOptions);
|
|
211
220
|
private discoverProtectedResource;
|
|
212
|
-
discover(resourceUrl: string | URL, { resourceMetadataUrl }?: OAuthMetadataLookupOptions): Promise<OAuthDiscoveryResult>;
|
|
221
|
+
discover(resourceUrl: string | URL, { resourceMetadataUrl, signal }?: OAuthMetadataLookupOptions): Promise<OAuthDiscoveryResult>;
|
|
213
222
|
}
|
|
214
223
|
declare function discoverOAuthMetadata(resourceUrl: string | URL, options?: OAuthMetadataDiscoveryOptions & OAuthMetadataLookupOptions): Promise<OAuthDiscoveryResult>;
|
|
215
224
|
|
|
@@ -583,6 +592,11 @@ interface StdioTransportOptions {
|
|
|
583
592
|
spawn?: StdioSpawn;
|
|
584
593
|
}
|
|
585
594
|
type HttpTransportFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
595
|
+
declare class HttpTransportError extends Error {
|
|
596
|
+
readonly status: number;
|
|
597
|
+
readonly method: "GET" | "POST" | "DELETE";
|
|
598
|
+
constructor(message: string, status: number, method: "GET" | "POST" | "DELETE");
|
|
599
|
+
}
|
|
586
600
|
interface HttpTransportOptions {
|
|
587
601
|
url: string;
|
|
588
602
|
/** Legacy SSE uses a GET stream that announces the RPC POST endpoint. */
|
|
@@ -629,6 +643,7 @@ declare class HttpTransport implements McpTransport {
|
|
|
629
643
|
private readonly oauthProvider;
|
|
630
644
|
private readonly oauthMetadataDiscovery;
|
|
631
645
|
private readonly inFlightFetchAbortControllers;
|
|
646
|
+
private readonly inFlightOAuthAbortControllers;
|
|
632
647
|
private readonly openResponseReaders;
|
|
633
648
|
private readonly modernRequests;
|
|
634
649
|
private modernMode;
|
|
@@ -743,5 +758,5 @@ declare class JsonRpcMessageLayer {
|
|
|
743
758
|
private handleCancellationNotification;
|
|
744
759
|
}
|
|
745
760
|
|
|
746
|
-
export { ERROR_INTERNAL, ERROR_INVALID_PARAMS, ERROR_INVALID_REQUEST, ERROR_METHOD_NOT_FOUND, ERROR_PARSE, HttpTransport, JsonRpcMessageLayer, McpClient, McpError, OAuthMetadataDiscovery, StdioTransport, createInMemoryTransportPair, createSdkTestPair, createTestPair, discoverOAuthMetadata, fetchMcpResponse };
|
|
761
|
+
export { ERROR_INTERNAL, ERROR_INVALID_PARAMS, ERROR_INVALID_REQUEST, ERROR_METHOD_NOT_FOUND, ERROR_PARSE, HttpTransport, HttpTransportError, JsonRpcMessageLayer, McpClient, McpError, OAuthMetadataDiscovery, StdioTransport, createInMemoryTransportPair, createSdkTestPair, createTestPair, discoverOAuthMetadata, fetchMcpResponse };
|
|
747
762
|
export type { AudioContent, BlobResourceContents, CacheableResultMetadata, CallToolOptions, CallToolParams, CallToolResult, ClientCapabilities, CompleteArgument, CompleteParams, CompleteResult, Completion, ConnectResult, ContentItem, CreateMessageParams, CreateMessageResult, ElicitationParams, ElicitationResult, EmbeddedResource, GetPromptParams, GetPromptResult, HttpTransportFetch, HttpTransportOptions, ImageContent, InMemoryTransportPair, IncludeContext, InitializeParams, InitializeResult, JsonRpcErrorObject, JsonRpcErrorResponse, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcRequestOptions, JsonRpcResponse, JsonRpcSuccessResponse, LogLevel, LogMessage, McpClientConnection, McpClientOptions, McpRequestContext, McpSubscription, McpTransport, McpTransportClosedEvent, ModelHint, ModelPreferences, NotificationFilter, OAuthAuthorizationServerMetadata, OAuthClientProvider, OAuthClientProviderOptions, OAuthDiscoveryCache, OAuthDiscoveryResult, OAuthMetadataFetch, OAuthProtectedResourceMetadata, OAuthSessionStore, OAuthUnauthorizedChallenge, PaginatedParams, PaginatedResult, ProgressParams, ProgressToken, PromptMessage, PromptReference, ReadResourceParams, RequestId, ResourceContents, ResourceReference, ResultMetadata, Root, SamplingContent, SamplingMessage, SdkTestPair, ServerCapabilities, StdioSpawn, StdioTransportOptions, StoredOAuthSession, SubscriptionOptions, TextContent, TextResourceContents, Tool, ToolResultContent, ToolUseContent };
|