mcp-compress-router 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import * as http from 'node:http';
3
+ /**
4
+ * Starts a temporary loopback HTTP server on `callbackPort`, runs the
5
+ * authorization-code flow through `beginAuthorization`, and waits for the
6
+ * browser redirect carrying the authorization code. The callback must echo
7
+ * the generated `state`; when it carries `iss`, the value must match the
8
+ * expected authorization-server issuer.
9
+ *
10
+ * The callback timeout comes from `MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS`
11
+ * (default 120 seconds).
12
+ *
13
+ * @param options - Callback server inputs.
14
+ * @returns The authorization code and the `startAuthorization` result.
15
+ * @throws If the server cannot bind, `beginAuthorization` fails, the
16
+ * authorization server redirects with an `error`, or the callback does
17
+ * not arrive before the timeout. A callback that fails state or issuer
18
+ * validation is ignored — it never settles the flow, so it surfaces as
19
+ * a timeout instead.
20
+ */
21
+ export async function acquireAuthorizationCode(options) {
22
+ return startCallbackServerAndWait({
23
+ options,
24
+ state: randomBytes(32).toString('base64url'),
25
+ timeoutMs: readTimeoutMs(),
26
+ timeoutHandle: { current: undefined },
27
+ authResultRef: { value: undefined },
28
+ });
29
+ }
30
+ /**
31
+ * Reads the OAuth login timeout from the
32
+ * `MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS` env var, defaulting to 120 seconds.
33
+ *
34
+ * @returns Timeout in milliseconds.
35
+ */
36
+ function readTimeoutMs() {
37
+ const env = process.env.MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS;
38
+ if (env) {
39
+ const parsed = parseInt(env, 10);
40
+ if (!isNaN(parsed) && parsed > 0)
41
+ return parsed;
42
+ }
43
+ return 120_000;
44
+ }
45
+ /**
46
+ * Creates the temporary HTTP server, binds it to the loopback interface,
47
+ * and resolves with the authorization code once the browser delivers the
48
+ * callback.
49
+ *
50
+ * @param flow - Callback flow state for this login attempt.
51
+ * @returns The authorization code and the `startAuthorization` result.
52
+ */
53
+ async function startCallbackServerAndWait(flow) {
54
+ const authorizationCode = await new Promise((resolve, reject) => {
55
+ const tempServer = http.createServer();
56
+ tempServer.on('request', makeCallbackHandler(flow, tempServer, resolve, reject));
57
+ tempServer.listen(flow.options.callbackPort, '127.0.0.1', onServerListen(flow, tempServer, reject));
58
+ tempServer.on('error', (err) => {
59
+ if (flow.timeoutHandle.current)
60
+ clearTimeout(flow.timeoutHandle.current);
61
+ reject(err);
62
+ });
63
+ });
64
+ if (flow.timeoutHandle.current)
65
+ clearTimeout(flow.timeoutHandle.current);
66
+ return { authorizationCode, authResult: flow.authResultRef.value };
67
+ }
68
+ /**
69
+ * Validates the callback query against the login attempt: the CSRF
70
+ * `state` must match, and a present `iss` must equal the expected
71
+ * authorization-server issuer (a missing `iss` fails validation when the
72
+ * server advertised RFC 9207 support).
73
+ *
74
+ * @param flow - Callback flow state for this login attempt.
75
+ * @param url - The parsed callback URL.
76
+ * @returns An error message when the callback must be ignored, or
77
+ * undefined when it is valid.
78
+ */
79
+ function validateCallback(flow, url) {
80
+ if (url.searchParams.get('state') !== flow.state) {
81
+ return 'OAuth callback state mismatch: the response does not belong to this login attempt.';
82
+ }
83
+ const iss = url.searchParams.get('iss');
84
+ if (iss !== null && iss !== flow.options.expectedIssuer) {
85
+ return `OAuth callback issuer mismatch: expected "${flow.options.expectedIssuer}", received "${iss}".`;
86
+ }
87
+ if (iss === null && flow.options.requireIssuer) {
88
+ return 'OAuth callback is missing the issuer parameter (iss) advertised by the authorization server.';
89
+ }
90
+ return undefined;
91
+ }
92
+ /**
93
+ * Escapes the HTML special characters in a reflected value, so an OAuth
94
+ * error string cannot inject markup into the callback page.
95
+ *
96
+ * @param value - The raw value to escape.
97
+ * @returns The escaped value.
98
+ */
99
+ function escapeHtml(value) {
100
+ return value
101
+ .replaceAll('&', '&')
102
+ .replaceAll('<', '&lt;')
103
+ .replaceAll('>', '&gt;')
104
+ .replaceAll('"', '&quot;')
105
+ .replaceAll("'", '&#39;');
106
+ }
107
+ /**
108
+ * Creates the HTTP request listener for the temporary callback server.
109
+ *
110
+ * Validates the CSRF `state` echoed by the authorization server and, when
111
+ * present, the RFC 9207 `iss` issuer parameter before acting on the
112
+ * callback. A callback that fails validation is ignored: it gets a 400
113
+ * response but never settles the flow, so a stray loopback request cannot
114
+ * abort a login whose legitimate callback is still on its way. Uses
115
+ * `tempServer` captured via closure to close the listener once the
116
+ * callback settles.
117
+ *
118
+ * @param flow - Callback flow state for this login attempt.
119
+ * @param tempServer - The temporary callback server (closed on completion).
120
+ * @param resolve - Promise resolve function (authorization code).
121
+ * @param reject - Promise reject function.
122
+ * @returns An HTTP request listener.
123
+ */
124
+ function makeCallbackHandler(flow, tempServer, resolve, reject) {
125
+ const fail = (message) => {
126
+ if (flow.timeoutHandle.current)
127
+ clearTimeout(flow.timeoutHandle.current);
128
+ tempServer.close();
129
+ reject(new Error(message));
130
+ };
131
+ return (req, res) => {
132
+ const url = new URL(req.url, `http://127.0.0.1`);
133
+ const error = url.searchParams.get('error');
134
+ const code = url.searchParams.get('code');
135
+ // Only authorization responses (a code or an error) carry a state to
136
+ // validate; anything else is not a callback.
137
+ if (error !== null || code !== null) {
138
+ const validationError = validateCallback(flow, url);
139
+ if (validationError) {
140
+ res.writeHead(400, { 'Content-Type': 'text/html' });
141
+ res.end('<html><body><h1>Authorization failed</h1><p>Invalid OAuth callback.</p></body></html>');
142
+ return;
143
+ }
144
+ }
145
+ if (error !== null) {
146
+ res.writeHead(400, { 'Content-Type': 'text/html' });
147
+ res.end(`<html><body><h1>Authorization failed</h1><p>${escapeHtml(error)}</p></body></html>`);
148
+ fail(`Authorization failed: ${error}`);
149
+ return;
150
+ }
151
+ if (code === null) {
152
+ res.writeHead(404);
153
+ res.end('Not found');
154
+ return;
155
+ }
156
+ if (flow.timeoutHandle.current)
157
+ clearTimeout(flow.timeoutHandle.current);
158
+ res.writeHead(200, { 'Content-Type': 'text/html' });
159
+ res.end('<html><body><h1>Authorization successful!</h1><p>You can close this window.</p></body></html>');
160
+ tempServer.close();
161
+ resolve(code);
162
+ };
163
+ }
164
+ /**
165
+ * Listen callback: records the bound port, prepares the authorization
166
+ * request, opens the browser, and arms the callback timeout. Closes the
167
+ * listener when preparation fails so the process is not kept alive while
168
+ * the error is reported.
169
+ *
170
+ * @param flow - Callback flow state for this login attempt.
171
+ * @param tempServer - The temporary callback server.
172
+ * @param reject - Promise reject function.
173
+ * @returns The async listen handler.
174
+ */
175
+ function onServerListen(flow, tempServer, reject) {
176
+ return async () => {
177
+ try {
178
+ const address = tempServer.address();
179
+ if (!address || typeof address === 'string') {
180
+ throw new Error('Failed to get server address');
181
+ }
182
+ // Record the OS-assigned port before the authorization request, so
183
+ // it (and the token exchange that follows) carries the real
184
+ // redirect URI.
185
+ flow.options.mgr.setActualPort(address.port);
186
+ flow.authResultRef.value = await flow.options.beginAuthorization(flow.state);
187
+ await flow.options.mgr.saveCodeVerifier(flow.authResultRef.value.codeVerifier);
188
+ void flow.options.openBrowser(flow.authResultRef.value.authorizationUrl.toString());
189
+ flow.timeoutHandle.current = setTimeout(() => {
190
+ tempServer.close();
191
+ reject(new Error(`OAuth login timed out after ${flow.timeoutMs / 1000} seconds. Please try again.`));
192
+ }, flow.timeoutMs);
193
+ }
194
+ catch (err) {
195
+ // Close the listener so a failed setup cannot keep the CLI process
196
+ // alive while the error is reported.
197
+ tempServer.close();
198
+ reject(err instanceof Error ? err : new Error(String(err)));
199
+ }
200
+ };
201
+ }
@@ -2,6 +2,8 @@ import { Logger } from '../utils/index.js';
2
2
  import { ensureConfigDir, readConfigFile } from './config-io.js';
3
3
  import { loadConfig } from '../services/config.js';
4
4
  import { discoverAuth, discoverSingleServer, saveToolCache } from '../services/index.js';
5
+ import { OAUTH_LOOPBACK_URI } from '../services/oauth.js';
6
+ import { acquireAuthorizationCode } from './login-callback-server.js';
5
7
  /**
6
8
  * Validates that a server exists in config and is eligible for OAuth login.
7
9
  *
@@ -39,158 +41,124 @@ async function _getSdkAuth() {
39
41
  }
40
42
  return _sdkAuth;
41
43
  }
44
+ // Cached lazy import for the browser launcher.
45
+ let _openBrowser;
42
46
  /**
43
- * Discovers OAuth metadata and registers the client if dynamic registration
44
- * is needed.
47
+ * Returns the browser launcher, loading the module on first use.
48
+ *
49
+ * @returns The `openBrowser` function.
50
+ */
51
+ async function _getOpenBrowser() {
52
+ if (!_openBrowser) {
53
+ _openBrowser = (await import('../utils/open-browser.js')).openBrowser;
54
+ }
55
+ return _openBrowser;
56
+ }
57
+ /**
58
+ * Discovers the OAuth authorization server metadata for a server.
45
59
  *
46
- * @param mgr - OAuth credential manager for the target server.
47
60
  * @param targetServer - Typed downstream server configuration.
48
61
  * @param name - Server name (for error messages).
49
- * @returns The discovered OAuth metadata (non-null after validation).
50
- * @throws If the server does not expose OAuth metadata or registration.
62
+ * @returns The discovered OAuth metadata and whether the authorization
63
+ * server commits to the RFC 9207 `iss` parameter (read from the raw
64
+ * metadata document, so it also covers OpenID Connect discovery).
65
+ * @throws If the server does not expose OAuth metadata.
51
66
  */
52
- async function setupOAuthClient(mgr, targetServer, name) {
53
- const { registerClient } = await _getSdkAuth();
67
+ async function discoverOAuthMetadata(targetServer, name) {
54
68
  const serverUrl = new URL(targetServer.url);
55
69
  const discovered = await discoverAuth(serverUrl);
56
70
  const metadata = discovered.serverMetadata;
57
71
  if (!metadata) {
58
72
  throw new Error(`Server "${name}" does not expose OAuth metadata.`);
59
73
  }
60
- // DCR is only required when there is no pre-registered client (static
61
- // override) and no previously stored client information. Servers without a
62
- // registration_endpoint (e.g. GitHub) work when an "oauth.clientId"
63
- // override is configured.
64
- if (!mgr.hasStaticClient() && !(await mgr.clientInformation())) {
65
- if (!metadata.registration_endpoint) {
66
- throw new Error(`Server "${name}" does not support dynamic client registration. Configure an "oauth.clientId" override in mcp.json with a pre-registered client ID.`);
67
- }
68
- const regResult = await registerClient(new URL(metadata.registration_endpoint), {
69
- metadata,
70
- clientMetadata: mgr.clientMetadata,
71
- });
72
- await mgr.saveClientInformation(regResult);
73
- }
74
- return metadata;
74
+ return {
75
+ metadata: metadata,
76
+ requireIssuer: discovered.authorizationResponseIssParameterSupported,
77
+ };
75
78
  }
76
79
  /**
77
- * Starts a temporary HTTP server, opens the browser for authorization,
78
- * and waits for the OAuth callback with a configurable timeout.
80
+ * Whether a stored client registration covers the portless loopback
81
+ * redirect URI. The comparison ignores the port (RFC 8252 §8.4 excludes
82
+ * it from loopback redirect matching) but requires the scheme, host, and
83
+ * path to match, so a registration for a different host (e.g.
84
+ * `localhost`) is not reused.
79
85
  *
80
- * @param mgr - OAuth credential manager.
81
- * @param metadata - Discovered OAuth metadata.
82
- * @param targetServer - Typed downstream server configuration.
83
- * @returns The authorization code and the full `startAuthorization` result.
84
- * @throws If the callback times out or the authorization is denied.
86
+ * @param client - The stored client registration.
87
+ * @returns True when at least one registered redirect URI matches
88
+ * {@link OAUTH_LOOPBACK_URI} ignoring the port.
85
89
  */
86
- async function acquireAuthorizationCode(mgr, metadata, targetServer, callbackPort) {
87
- const { startAuthorization } = await _getSdkAuth();
88
- const { openBrowser } = await import('../utils/open-browser.js');
89
- const TIMEOUT_MS = _readTimeoutMs();
90
- return _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, callbackPort);
90
+ function _registrationCoversLoopback(client) {
91
+ if (!('redirect_uris' in client) || client.redirect_uris.length === 0) {
92
+ return false;
93
+ }
94
+ const expected = new URL(OAUTH_LOOPBACK_URI);
95
+ return client.redirect_uris.some((entry) => {
96
+ let parsed;
97
+ try {
98
+ parsed = new URL(entry);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ return (parsed.protocol === expected.protocol &&
104
+ parsed.hostname === expected.hostname &&
105
+ parsed.pathname === expected.pathname);
106
+ });
91
107
  }
92
108
  /**
93
- * Creates the HTTP request listener for the temporary OAuth callback server.
109
+ * Registers a client through dynamic client registration (RFC 7591) unless
110
+ * a static `oauth.clientId` override is configured or a stored registration
111
+ * already covers the loopback redirect URI. Registration does not depend on
112
+ * the callback port: the registered URI is the portless
113
+ * {@link OAUTH_LOOPBACK_URI}, and RFC 8252 §8.4 excludes the port from
114
+ * loopback redirect matching.
94
115
  *
95
- * Uses `tempServer` captured via closure for calling `.close()` and the
96
- * `resolve`/`reject` functions to settle the promise. The `timeoutHandle`
97
- * wrapper allows the callback to clear the timeout when a response arrives.
98
- *
99
- * @param timeoutHandle - Mutable object wrapping the timeout ID.
100
- * @param tempServer - The temporary HTTP server (to close on completion).
101
- * @param resolve - Promise resolve function.
102
- * @param reject - Promise reject function.
103
- * @returns An HTTP request listener.
116
+ * @param mgr - OAuth credential manager for the target server.
117
+ * @param metadata - Discovered OAuth metadata.
118
+ * @param name - Server name (for error messages).
119
+ * @throws If the server does not support dynamic client registration and no
120
+ * reusable registration or static client is available.
104
121
  */
105
- function _makeCallbackHandler(timeoutHandle, tempServer, resolve, reject) {
106
- return (req, res) => {
107
- const url = new URL(req.url, `http://localhost`);
108
- const code = url.searchParams.get('code');
109
- const error = url.searchParams.get('error');
110
- if (code) {
111
- if (timeoutHandle.current)
112
- clearTimeout(timeoutHandle.current);
113
- res.writeHead(200, { 'Content-Type': 'text/html' });
114
- res.end('<html><body><h1>Authorization successful!</h1><p>You can close this window.</p></body></html>');
115
- tempServer.close();
116
- resolve(code);
117
- }
118
- else if (error) {
119
- if (timeoutHandle.current)
120
- clearTimeout(timeoutHandle.current);
121
- res.writeHead(400, { 'Content-Type': 'text/html' });
122
- res.end(`<html><body><h1>Authorization failed</h1><p>${error}</p></body></html>`);
123
- tempServer.close();
124
- reject(new Error(`Authorization failed: ${error}`));
125
- }
126
- else {
127
- res.writeHead(404);
128
- res.end('Not found');
129
- }
130
- };
131
- }
132
- /** Creates a temp HTTP server, starts OAuth flow, waits for callback. */
133
- async function _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, callbackPort) {
134
- const http = await import('node:http');
135
- const timeoutHandle = { current: undefined };
136
- const authResultRef = { value: undefined };
137
- const authorizationCode = await new Promise((resolve, reject) => {
138
- const tempServer = http.createServer();
139
- tempServer.on('request', _makeCallbackHandler(timeoutHandle, tempServer, resolve, reject));
140
- tempServer.listen(callbackPort, _onServerListen(tempServer, mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, timeoutHandle, authResultRef, reject));
141
- tempServer.on('error', (err) => {
142
- if (timeoutHandle.current)
143
- clearTimeout(timeoutHandle.current);
144
- reject(err);
145
- });
122
+ async function registerClientIfNeeded(mgr, metadata, name) {
123
+ if (mgr.hasStaticClient()) {
124
+ return;
125
+ }
126
+ const stored = await mgr.clientInformation();
127
+ if (stored && _registrationCoversLoopback(stored)) {
128
+ return;
129
+ }
130
+ if (!metadata.registration_endpoint) {
131
+ throw new Error(`Server "${name}" does not support dynamic client registration. Configure an "oauth.clientId" override in mcp.json with a pre-registered client ID.`);
132
+ }
133
+ const { registerClient } = await _getSdkAuth();
134
+ const registration = await registerClient(new URL(metadata.registration_endpoint), {
135
+ metadata,
136
+ clientMetadata: mgr.clientMetadata,
146
137
  });
147
- if (timeoutHandle.current)
148
- clearTimeout(timeoutHandle.current);
149
- return { authorizationCode, authResult: authResultRef.value };
150
- }
151
- /** Listen callback: sets port, starts auth, opens browser, arms timeout. */
152
- function _onServerListen(tempServer, mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, timeoutHandle, authResultRef, reject) {
153
- return async () => {
154
- try {
155
- const address = tempServer.address();
156
- if (!address || typeof address === 'string') {
157
- reject(new Error('Failed to get server address'));
158
- return;
159
- }
160
- const actualPort = address.port;
161
- mgr.setActualPort(actualPort);
162
- authResultRef.value = await startAuthorization(new URL(metadata.authorization_endpoint), {
163
- metadata,
164
- clientInformation: (await mgr.clientInformation()),
165
- redirectUrl: mgr.redirectUrl,
166
- scope: targetServer.oauth?.scope,
167
- });
168
- await mgr.saveCodeVerifier(authResultRef.value.codeVerifier);
169
- void openBrowser(authResultRef.value.authorizationUrl.toString());
170
- timeoutHandle.current = setTimeout(() => {
171
- tempServer.close();
172
- reject(new Error(`OAuth login timed out after ${TIMEOUT_MS / 1000} seconds. Please try again.`));
173
- }, TIMEOUT_MS);
174
- }
175
- catch (err) {
176
- reject(err instanceof Error ? err : new Error(String(err)));
177
- }
178
- };
138
+ await mgr.saveClientInformation(registration);
179
139
  }
180
140
  /**
181
- * Reads the OAuth login timeout from the
182
- * `MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS` env var, defaulting to 120 seconds.
141
+ * Registers the client if needed and builds the authorization URL. Passed to
142
+ * the callback server as its post-listen hook, so it runs once the real
143
+ * callback port is known and the authorization request carries it.
183
144
  *
184
- * @returns Timeout in milliseconds.
145
+ * @param mgr - OAuth credential manager for the target server.
146
+ * @param metadata - Discovered OAuth metadata.
147
+ * @param targetServer - Typed downstream server configuration.
148
+ * @param name - Server name (for error messages).
149
+ * @param state - CSRF state generated for this login attempt.
150
+ * @returns The `startAuthorization` result.
185
151
  */
186
- function _readTimeoutMs() {
187
- const env = process.env.MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS;
188
- if (env) {
189
- const parsed = parseInt(env, 10);
190
- if (!isNaN(parsed) && parsed > 0)
191
- return parsed;
192
- }
193
- return 120_000;
152
+ async function beginAuthorization(mgr, metadata, targetServer, name, state) {
153
+ await registerClientIfNeeded(mgr, metadata, name);
154
+ const { startAuthorization } = await _getSdkAuth();
155
+ return startAuthorization(new URL(metadata.authorization_endpoint), {
156
+ metadata,
157
+ clientInformation: (await mgr.clientInformation()),
158
+ redirectUrl: mgr.redirectUrl,
159
+ scope: targetServer.oauth?.scope,
160
+ state,
161
+ });
194
162
  }
195
163
  /**
196
164
  * Validates a requested callback port override from the `--port` flag.
@@ -230,9 +198,11 @@ function _resolveCallbackPort(override, server) {
230
198
  *
231
199
  * Validates the server exists in config and is an HTTP type.
232
200
  * For HTTP servers, runs the OAuth authorization-code flow
233
- * using the SDK's OAuth client infrastructure. The flow opens
234
- * a browser, handles the redirect callback, exchanges the
235
- * authorization code for tokens, and persists them in credentials.json.
201
+ * using the SDK's OAuth client infrastructure. The flow binds a
202
+ * temporary loopback callback server, registers the client (when
203
+ * needed) with the portless loopback callback URI, opens a browser,
204
+ * handles the redirect callback, exchanges the authorization code for
205
+ * tokens, and persists them in credentials.json.
236
206
  *
237
207
  * @param configPath - Absolute path to the mcp.json file.
238
208
  * @param name - Server name to authenticate.
@@ -249,8 +219,15 @@ export async function handleLogin(configPath, name, portOverride) {
249
219
  const callbackPort = _resolveCallbackPort(_validatePortOverride(portOverride), targetServer);
250
220
  const { OAuthCredentialManager } = await import('../services/oauth.js');
251
221
  const mgr = new OAuthCredentialManager(configPath, targetServer);
252
- const metadata = await setupOAuthClient(mgr, targetServer, name);
253
- const { authorizationCode, authResult } = await acquireAuthorizationCode(mgr, metadata, targetServer, callbackPort);
222
+ const { metadata, requireIssuer } = await discoverOAuthMetadata(targetServer, name);
223
+ const { authorizationCode, authResult } = await acquireAuthorizationCode({
224
+ mgr,
225
+ callbackPort,
226
+ openBrowser: await _getOpenBrowser(),
227
+ expectedIssuer: metadata.issuer,
228
+ requireIssuer,
229
+ beginAuthorization: (state) => beginAuthorization(mgr, metadata, targetServer, name, state),
230
+ });
254
231
  const { exchangeAuthorization } = await _getSdkAuth();
255
232
  const realRedirectUrl = mgr.redirectUrl;
256
233
  const tokens = await exchangeAuthorization(new URL(metadata.token_endpoint), {
@@ -33,6 +33,49 @@ function toUrl(value) {
33
33
  return undefined;
34
34
  }
35
35
  }
36
+ /**
37
+ * Wraps a fetch function so the raw JSON body of every successful
38
+ * response is captured. The SDK parses OpenID Connect discovery metadata
39
+ * with a schema that strips undeclared fields (such as the RFC 9207
40
+ * `authorization_response_iss_parameter_supported` flag), so the raw
41
+ * document is the only place those fields remain observable.
42
+ *
43
+ * The SDK's discovery helper returns as soon as one discovery URL yields
44
+ * metadata, so the last captured body belongs to the response the
45
+ * returned metadata was parsed from.
46
+ *
47
+ * @param base - The fetch function to wrap.
48
+ * @param capture - Sink for the raw JSON body.
49
+ * @returns A fetch function that captures successful JSON responses.
50
+ */
51
+ function createRawMetadataFetch(base, capture) {
52
+ return async (input, init) => {
53
+ const response = await base(input, init);
54
+ if (response.ok) {
55
+ try {
56
+ const raw = await response.clone().json();
57
+ if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
58
+ capture.raw = raw;
59
+ }
60
+ }
61
+ catch {
62
+ // A non-JSON body is a clean discovery miss, not metadata; the
63
+ // SDK reports it to the caller.
64
+ }
65
+ }
66
+ return response;
67
+ };
68
+ }
69
+ /**
70
+ * Reads the RFC 9207 `authorization_response_iss_parameter_supported`
71
+ * flag from the captured raw metadata document.
72
+ *
73
+ * @param capture - The captured raw metadata, when one was parsed.
74
+ * @returns True when the document declares the flag as `true`.
75
+ */
76
+ function readIssParameterSupported(capture) {
77
+ return capture.raw?.authorization_response_iss_parameter_supported === true;
78
+ }
36
79
  /**
37
80
  * Races every candidate probe in parallel and resolves with the first
38
81
  * candidate that finds metadata. Candidates that miss (or error) are
@@ -40,7 +83,8 @@ function toUrl(value) {
40
83
  * delay a hit found by another candidate.
41
84
  *
42
85
  * @param urls - The candidate URLs to probe.
43
- * @param probe - The per-candidate probe callback (never throws).
86
+ * @param probe - The per-candidate probe callback (never throws; returns
87
+ * `undefined` on a miss).
44
88
  * @returns The first hit, or `undefined` when every candidate missed.
45
89
  */
46
90
  async function raceCandidates(urls, probe) {
@@ -48,13 +92,58 @@ async function raceCandidates(urls, probe) {
48
92
  return undefined;
49
93
  }
50
94
  return Promise.any(urls.map(async (url) => {
51
- const metadata = await probe(url);
52
- if (!metadata) {
95
+ const result = await probe(url);
96
+ if (!result) {
53
97
  throw new Error(`No OAuth metadata at ${url.href}`);
54
98
  }
55
- return { url, metadata };
99
+ return result;
56
100
  })).catch(() => undefined);
57
101
  }
102
+ /**
103
+ * Builds the authorization-server candidate URL groups for discovery.
104
+ * Advertised AS URLs (from RFC 9728 Protected Resource Metadata) take
105
+ * precedence; the legacy fallback group — the server URL itself and, for
106
+ * subpath URLs, its origin root — is only probed when no advertised AS
107
+ * yields metadata.
108
+ *
109
+ * @param serverUrl - The downstream MCP server URL.
110
+ * @param resourceMetadata - Discovered Protected Resource Metadata, if any.
111
+ * @returns The advertised and fallback candidate URL lists.
112
+ */
113
+ function buildCandidateUrls(serverUrl, resourceMetadata) {
114
+ const advertised = (resourceMetadata?.authorization_servers ?? [])
115
+ .map((value) => toUrl(value))
116
+ .filter((url) => url !== undefined);
117
+ const fallback = [serverUrl];
118
+ if (serverUrl.pathname !== '/') {
119
+ fallback.push(new URL(serverUrl.origin));
120
+ }
121
+ return { advertised, fallback };
122
+ }
123
+ /**
124
+ * Builds the discovery result from an optional candidate hit.
125
+ *
126
+ * @param resourceMetadata - Discovered Protected Resource Metadata, if any.
127
+ * @param hit - The winning AS candidate, or `undefined` on a full miss.
128
+ * @param serverUrl - The downstream MCP server URL (fallback AS URL).
129
+ * @returns The discovery result.
130
+ */
131
+ function toDiscoveredAuth(resourceMetadata, hit, serverUrl) {
132
+ if (!hit) {
133
+ return {
134
+ resourceMetadata,
135
+ serverMetadata: undefined,
136
+ authorizationServerUrl: serverUrl,
137
+ authorizationResponseIssParameterSupported: false,
138
+ };
139
+ }
140
+ return {
141
+ resourceMetadata,
142
+ serverMetadata: hit.metadata,
143
+ authorizationServerUrl: hit.url,
144
+ authorizationResponseIssParameterSupported: hit.authorizationResponseIssParameterSupported,
145
+ };
146
+ }
58
147
  /**
59
148
  * Discovers OAuth metadata for a downstream MCP server following the
60
149
  * MCP 2025-06-18 authorization spec two-step flow:
@@ -104,9 +193,22 @@ export async function discoverAuth(serverUrl) {
104
193
  let lastError;
105
194
  // Tolerant AS discovery: any error (404-as-throw, 5xx, network) is
106
195
  // recorded and treated as "not found" so the other candidates are tried.
196
+ // The raw metadata document is captured alongside the SDK's parsed
197
+ // result so RFC 9207 fields stripped by the OIDC schema stay visible.
107
198
  const safeDiscoverAs = async (url) => {
199
+ const capture = {};
108
200
  try {
109
- return await discoverAuthorizationServerMetadata(url, { fetchFn });
201
+ const metadata = await discoverAuthorizationServerMetadata(url, {
202
+ fetchFn: createRawMetadataFetch(fetchFn, capture),
203
+ });
204
+ if (!metadata) {
205
+ return undefined;
206
+ }
207
+ return {
208
+ url,
209
+ metadata,
210
+ authorizationResponseIssParameterSupported: readIssParameterSupported(capture),
211
+ };
110
212
  }
111
213
  catch (err) {
112
214
  if (!isNonJsonResponse(err)) {
@@ -126,32 +228,15 @@ export async function discoverAuth(serverUrl) {
126
228
  catch {
127
229
  // No PRM published; fall through to direct AS discovery below.
128
230
  }
129
- // Step 2: probe the candidates in parallel, first hit wins. Advertised
130
- // AS URLs take precedence; the legacy fallback group (the server URL
131
- // and, for subpath URLs, its origin root) is only probed when no
132
- // advertised AS yields metadata.
133
- const advertisedUrls = (resourceMetadata?.authorization_servers ?? [])
134
- .map((value) => toUrl(value))
135
- .filter((url) => url !== undefined);
136
- const fallbackUrls = [serverUrl];
137
- if (serverUrl.pathname !== '/') {
138
- fallbackUrls.push(new URL(serverUrl.origin));
139
- }
140
- const advertisedHit = await raceCandidates(advertisedUrls, safeDiscoverAs);
231
+ // Step 2: probe the candidates in parallel, first hit wins.
232
+ const { advertised, fallback } = buildCandidateUrls(serverUrl, resourceMetadata);
233
+ const advertisedHit = await raceCandidates(advertised, safeDiscoverAs);
141
234
  if (advertisedHit) {
142
- return {
143
- resourceMetadata,
144
- serverMetadata: advertisedHit.metadata,
145
- authorizationServerUrl: advertisedHit.url,
146
- };
235
+ return toDiscoveredAuth(resourceMetadata, advertisedHit, serverUrl);
147
236
  }
148
- const fallbackHit = await raceCandidates(fallbackUrls, safeDiscoverAs);
237
+ const fallbackHit = await raceCandidates(fallback, safeDiscoverAs);
149
238
  if (fallbackHit) {
150
- return {
151
- resourceMetadata,
152
- serverMetadata: fallbackHit.metadata,
153
- authorizationServerUrl: fallbackHit.url,
154
- };
239
+ return toDiscoveredAuth(resourceMetadata, fallbackHit, serverUrl);
155
240
  }
156
241
  // No metadata found anywhere. If any candidate actually errored (vs. a
157
242
  // clean 404 or a non-JSON response), surface that so callers can report
@@ -159,5 +244,5 @@ export async function discoverAuth(serverUrl) {
159
244
  if (lastError !== undefined) {
160
245
  throw lastError;
161
246
  }
162
- return { resourceMetadata, serverMetadata: undefined, authorizationServerUrl: serverUrl };
247
+ return toDiscoveredAuth(resourceMetadata, undefined, serverUrl);
163
248
  }
@@ -42,16 +42,28 @@ function needsRefresh(expiresAtIso) {
42
42
  }
43
43
  /**
44
44
  * The OAuth redirect callback path served by the temporary local HTTP
45
- * server started during `login`. The full redirect URI is
46
- * `http://localhost:<port>/mcp-compress-router/oauth-callback`, where
47
- * `<port>` is assigned by the OS. Register this path (on `localhost`,
48
- * any port) with OAuth providers that require a pre-registered client.
45
+ * server started during `login`. The authorization-request redirect URI
46
+ * is `http://127.0.0.1:<port>/mcp-compress-router/oauth-callback`, where
47
+ * `<port>` is assigned by the OS. Register this path (on the loopback
48
+ * interface, any port) with OAuth providers that require a pre-registered
49
+ * client.
49
50
  *
50
51
  * @internal Exported for tests only; not part of the public module API.
51
- * The constant is consumed internally by `redirectUrl`; tests import
52
- * it directly to avoid hardcoding the path string.
52
+ * The constant is consumed internally by `redirectUrl` and
53
+ * `OAUTH_LOOPBACK_URI`; tests import it directly to avoid hardcoding
54
+ * the path string.
53
55
  */
54
56
  export const OAUTH_CALLBACK_PATH = '/mcp-compress-router/oauth-callback';
57
+ /**
58
+ * The loopback redirect URI registered with OAuth providers through
59
+ * dynamic client registration. It carries no port: RFC 8252 §8.4
60
+ * excludes the port from loopback redirect matching, so one registration
61
+ * stays valid across logins no matter which port the callback server
62
+ * binds. The authorization request and the token exchange use
63
+ * {@link OAuthCredentialManager.redirectUrl} instead, which carries the
64
+ * actual port.
65
+ */
66
+ export const OAUTH_LOOPBACK_URI = `http://127.0.0.1${OAUTH_CALLBACK_PATH}`;
55
67
  /**
56
68
  * Implements OAuthClientProvider backed by credentials.json credential storage.
57
69
  *
@@ -85,20 +97,43 @@ export class OAuthCredentialManager {
85
97
  this._staticClientInfo = info;
86
98
  }
87
99
  }
100
+ /**
101
+ * The redirect URI sent with the authorization request and with the
102
+ * token exchange. Carries the actual port assigned by the OS (0 until
103
+ * {@link setActualPort} is called); both requests must send the same
104
+ * value (RFC 6749 §4.1.3). Dynamic client registration does not use
105
+ * it — the registered URI is the portless {@link OAUTH_LOOPBACK_URI}.
106
+ */
88
107
  get redirectUrl() {
89
- // Return the callback URL with the actual port assigned by the OS.
90
- // Falls back to port 0 until setActualPort() is called by login-command.
91
- return `http://localhost:${this._actualPort}${OAUTH_CALLBACK_PATH}`;
108
+ return `http://127.0.0.1:${this._actualPort}${OAUTH_CALLBACK_PATH}`;
92
109
  }
110
+ /**
111
+ * Client metadata used for dynamic client registration. The registered
112
+ * redirect URI is the portless loopback form
113
+ * ({@link OAUTH_LOOPBACK_URI}): RFC 8252 §8.4 excludes the port from
114
+ * loopback redirect matching, so the registration stays valid across
115
+ * logins regardless of the port the callback server binds.
116
+ *
117
+ * `application_type` is required for native clients by the MCP
118
+ * authorization specification (and by OIDC-aware registration
119
+ * endpoints, which otherwise default to "web"). The SDK's
120
+ * OAuthClientMetadata type does not declare it yet, so the return
121
+ * type widens explicitly; the SDK spreads this object into the
122
+ * registration body unchanged, so the field reaches the server.
123
+ */
93
124
  get clientMetadata() {
94
125
  return {
95
- redirect_uris: [this.redirectUrl],
126
+ redirect_uris: [OAUTH_LOOPBACK_URI],
96
127
  client_name: 'mcp-compress-router',
128
+ application_type: 'native',
97
129
  };
98
130
  }
99
131
  /**
100
132
  * Sets the actual listening port of the temporary HTTP callback server.
101
- * Must be called before startAuthorization so the redirect_uri is correct.
133
+ * Must be called before startAuthorization so the authorization request
134
+ * (and the token exchange that follows it) carries the correct
135
+ * redirect_uri. Dynamic client registration does not depend on it: the
136
+ * registered URI is the portless {@link OAUTH_LOOPBACK_URI}.
102
137
  *
103
138
  * @param port - The actual port the callback server is listening on.
104
139
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Compress all connected MCP servers into a single router MCP to save tokens",
5
5
  "license": "MIT",
6
6
  "type": "module",