mcp-compress-router 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,6 +27,8 @@
27
27
  - [Per-Server Tool Selection](#per-server-tool-selection)
28
28
  - [Inspecting Tools](#inspecting-tools)
29
29
  - [OAuth](#oauth)
30
+ - [Redirect URL](#redirect-url)
31
+ - [GitHub MCP (special case)](#github-mcp-special-case)
30
32
  - [Custom Headers](#custom-headers)
31
33
  - [Secrets and Variable Expansion](#secrets-and-variable-expansion)
32
34
  - [Connecting Coding Agents](#connecting-coding-agents)
@@ -329,6 +331,104 @@ the server entry (in `mcp.json`):
329
331
 
330
332
  Only `clientId` is required; `clientSecret` and `scope` are optional.
331
333
 
334
+ #### Redirect URL
335
+
336
+ During `login` the router starts a temporary local HTTP server and uses
337
+ a loopback redirect URI (per [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252)):
338
+
339
+ ```text
340
+ http://localhost:<port>/mcp-compress-router/oauth-callback
341
+ ```
342
+
343
+ `<port>` is chosen by the OS at login time, so there is no fixed port to
344
+ register. When a provider requires a pre-registered redirect URI,
345
+ register the loopback form **without a port**:
346
+
347
+ ```text
348
+ http://localhost/mcp-compress-router/oauth-callback
349
+ ```
350
+
351
+ Most providers (GitHub included) match the scheme, host, and path and
352
+ ignore the port on `localhost`. If your provider demands a redirect URI
353
+ with an **exact port**, pin it with `--port`:
354
+
355
+ ```bash
356
+ npx mcp-compress-router login my-http --port 8765
357
+ ```
358
+
359
+ This binds the callback server to `8765`, so the redirect URI becomes
360
+ `http://localhost:8765/mcp-compress-router/oauth-callback` — register
361
+ that exact URL with the provider. To reuse the same port on every
362
+ `login`, persist it in the server's `oauth` block instead of passing the
363
+ flag each time:
364
+
365
+ ```jsonc
366
+ "my-http": {
367
+ "type": "http",
368
+ "url": "https://example.com/mcp",
369
+ "oauth": { "clientId": "${ID}", "callbackPort": 8765 }
370
+ }
371
+ ```
372
+
373
+ `--port` overrides `oauth.callbackPort` for a single run. Pass `--port 0`
374
+ to force an OS-assigned port even when `oauth.callbackPort` is set.
375
+
376
+ #### GitHub MCP (special case)
377
+
378
+ The official GitHub MCP server at
379
+ `https://api.githubcopilot.com/mcp` advertises OAuth but does **not**
380
+ support Dynamic Client Registration, so you must pre-register a GitHub
381
+ OAuth App and pass its credentials via the `oauth` block. GitHub also
382
+ requires that the OAuth App be installed to the repositories and
383
+ organizations you want the MCP to access.
384
+
385
+ 1. **Create a GitHub OAuth App.**
386
+ Open <https://github.com/settings/developers> → *New OAuth App* (or
387
+ *Register an application*). Give it any name and homepage URL.
388
+ 2. **Configure the callback URL.**
389
+ Set the *Authorization callback URL* to:
390
+ `http://localhost/mcp-compress-router/oauth-callback`
391
+ 3. **Add the GitHub MCP server by URL.**
392
+
393
+ ```bash
394
+ npx mcp-compress-router add github https://api.githubcopilot.com/mcp
395
+ ```
396
+
397
+ 4. **Set `oauth` credentials in `mcp.json`.**
398
+ Copy the Client ID and generate a Client Secret, then put them in the
399
+ server entry (use variable expansion to keep secrets out of the
400
+ file):
401
+
402
+ ```jsonc
403
+ "github": {
404
+ "type": "http",
405
+ "url": "https://api.githubcopilot.com/mcp",
406
+ "oauth": {
407
+ "clientId": "${GITHUB_OAUTH_CLIENT_ID}",
408
+ "clientSecret": "${GITHUB_OAUTH_CLIENT_SECRET}",
409
+ "scope": "repo read:org"
410
+ }
411
+ }
412
+ ```
413
+
414
+ Request only the scopes the tools you need require; `repo read:org`
415
+ covers the common repo and organization operations. Put the actual
416
+ values in your `.env` file (see
417
+ [Secrets and Variable Expansion](#secrets-and-variable-expansion)).
418
+ 5. **Run the login command.**
419
+
420
+ ```bash
421
+ npx mcp-compress-router login github
422
+ ```
423
+
424
+ Your browser opens to authorize. After you approve, tokens are stored
425
+ in `credentials.json` and the router can call GitHub MCP tools.
426
+
427
+ > **Note:** if you used a *GitHub App* (not a classic OAuth App), the
428
+ > App must be installed to the accounts/repos you want to access before
429
+ > login will succeed, and its client secret is generated under *General*
430
+ > → *Generate a new client secret*.
431
+
332
432
  Other OAuth commands:
333
433
 
334
434
  ```bash
@@ -61,6 +61,17 @@ function buildServerEntry(opts) {
61
61
  if (opts.disabledTools && opts.disabledTools.length > 0) {
62
62
  entry.disabledTools = opts.disabledTools;
63
63
  }
64
+ // A fixed callback port only applies to HTTP servers (OAuth). Persist
65
+ // it on the `oauth` block so `login` reuses the same redirect URI.
66
+ if (opts.port !== undefined) {
67
+ if (type !== 'http') {
68
+ throw new Error('--port is only supported for HTTP servers (OAuth callback).');
69
+ }
70
+ if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535) {
71
+ throw new Error(`--port must be an integer between 1 and 65535 (got ${opts.port}).`);
72
+ }
73
+ entry.oauth = { callbackPort: opts.port };
74
+ }
64
75
  return { entry, type };
65
76
  }
66
77
  /**
@@ -107,10 +118,13 @@ export async function handleAdd(configPath, opts) {
107
118
  return result;
108
119
  }
109
120
  /**
110
- * Probes the server for OAuth metadata and, if found, runs the login
111
- * flow automatically. The probed auth requirement is cached in
112
- * `credentials.json` regardless of the login outcome so the `list`
113
- * command can show auth status without re-probing.
121
+ * Probes the server for OAuth metadata using the spec-compliant two-step
122
+ * discovery flow (RFC 9728 Protected Resource Metadata, then RFC 8414
123
+ * Authorization Server Metadata at each advertised authorization server)
124
+ * and, if OAuth is advertised, runs the login flow automatically. The
125
+ * probed auth requirement is cached in `credentials.json` regardless of
126
+ * the login outcome so the `list` command can show auth status without
127
+ * re-probing.
114
128
  *
115
129
  * @param configPath - Absolute path to the mcp.json file.
116
130
  * @param name - Server name just added.
@@ -119,12 +133,18 @@ export async function handleAdd(configPath, opts) {
119
133
  * does not advertise OAuth (or the probe failed).
120
134
  */
121
135
  async function tryAutoLogin(configPath, name, url) {
122
- const { discoverAuthorizationServerMetadata } = await import('@modelcontextprotocol/sdk/client/auth.js');
136
+ // Use the spec-compliant two-step discovery (RFC 9728 PRM, then RFC 8414
137
+ // AS metadata at each advertised authorization server). A one-step
138
+ // `discoverAuthorizationServerMetadata` probe misses servers that
139
+ // publish their AS only via PRM `authorization_servers` (e.g. Notion,
140
+ // whose AS metadata lives at the origin root, not the path-qualified
141
+ // well-known URL).
142
+ const { discoverAuth } = await import('../services/oauth-discovery.js');
123
143
  let requirement;
124
144
  let hasOAuth;
125
145
  try {
126
- const metadata = await discoverAuthorizationServerMetadata(new URL(url));
127
- hasOAuth = metadata !== undefined;
146
+ const discovered = await discoverAuth(new URL(url));
147
+ hasOAuth = Boolean(discovered.serverMetadata);
128
148
  requirement = hasOAuth ? 'oauth' : 'none';
129
149
  }
130
150
  catch {
@@ -1,5 +1,6 @@
1
1
  import { ensureConfigDir, readConfigFile } from './config-io.js';
2
2
  import { loadConfig } from '../services/config.js';
3
+ import { discoverAuth } from '../services/index.js';
3
4
  /**
4
5
  * Validates that a server exists in config and is eligible for OAuth login.
5
6
  *
@@ -48,16 +49,21 @@ async function _getSdkAuth() {
48
49
  * @throws If the server does not expose OAuth metadata or registration.
49
50
  */
50
51
  async function setupOAuthClient(mgr, targetServer, name) {
51
- const { discoverOAuthMetadata, registerClient } = await _getSdkAuth();
52
+ const { registerClient } = await _getSdkAuth();
52
53
  const serverUrl = new URL(targetServer.url);
53
- const metadata = await discoverOAuthMetadata(serverUrl);
54
+ const discovered = await discoverAuth(serverUrl);
55
+ const metadata = discovered.serverMetadata;
54
56
  if (!metadata) {
55
57
  throw new Error(`Server "${name}" does not expose OAuth metadata.`);
56
58
  }
57
- if (!metadata.registration_endpoint) {
58
- throw new Error(`Server "${name}" does not support dynamic client registration.`);
59
- }
59
+ // DCR is only required when there is no pre-registered client (static
60
+ // override) and no previously stored client information. Servers without a
61
+ // registration_endpoint (e.g. GitHub) work when an "oauth.clientId"
62
+ // override is configured.
60
63
  if (!mgr.hasStaticClient() && !(await mgr.clientInformation())) {
64
+ if (!metadata.registration_endpoint) {
65
+ 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.`);
66
+ }
61
67
  const regResult = await registerClient(new URL(metadata.registration_endpoint), {
62
68
  metadata,
63
69
  clientMetadata: mgr.clientMetadata,
@@ -76,11 +82,11 @@ async function setupOAuthClient(mgr, targetServer, name) {
76
82
  * @returns The authorization code and the full `startAuthorization` result.
77
83
  * @throws If the callback times out or the authorization is denied.
78
84
  */
79
- async function acquireAuthorizationCode(mgr, metadata, targetServer) {
85
+ async function acquireAuthorizationCode(mgr, metadata, targetServer, callbackPort) {
80
86
  const { startAuthorization } = await _getSdkAuth();
81
87
  const { openBrowser } = await import('../utils/open-browser.js');
82
88
  const TIMEOUT_MS = _readTimeoutMs();
83
- return _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS);
89
+ return _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, callbackPort);
84
90
  }
85
91
  /**
86
92
  * Creates the HTTP request listener for the temporary OAuth callback server.
@@ -123,14 +129,14 @@ function _makeCallbackHandler(timeoutHandle, tempServer, resolve, reject) {
123
129
  };
124
130
  }
125
131
  /** Creates a temp HTTP server, starts OAuth flow, waits for callback. */
126
- async function _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS) {
132
+ async function _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, callbackPort) {
127
133
  const http = await import('node:http');
128
134
  const timeoutHandle = { current: undefined };
129
135
  const authResultRef = { value: undefined };
130
136
  const authorizationCode = await new Promise((resolve, reject) => {
131
137
  const tempServer = http.createServer();
132
138
  tempServer.on('request', _makeCallbackHandler(timeoutHandle, tempServer, resolve, reject));
133
- tempServer.listen(0, _onServerListen(tempServer, mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, timeoutHandle, authResultRef, reject));
139
+ tempServer.listen(callbackPort, _onServerListen(tempServer, mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, timeoutHandle, authResultRef, reject));
134
140
  tempServer.on('error', (err) => {
135
141
  if (timeoutHandle.current)
136
142
  clearTimeout(timeoutHandle.current);
@@ -185,6 +191,39 @@ function _readTimeoutMs() {
185
191
  }
186
192
  return 120_000;
187
193
  }
194
+ /**
195
+ * Validates a requested callback port override from the `--port` flag.
196
+ *
197
+ * @param port - The raw port value (0 means "OS-assigned", undefined
198
+ * means "use config or OS-assigned").
199
+ * @returns The validated port number (0 or a positive integer
200
+ * 1-65535), or undefined when no override was given.
201
+ * @throws If the port is not a valid integer in range.
202
+ */
203
+ function _validatePortOverride(port) {
204
+ if (port === undefined) {
205
+ return undefined;
206
+ }
207
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
208
+ throw new Error(`--port must be an integer between 0 and 65535 (got ${port}). Use 0 to let the OS assign a port.`);
209
+ }
210
+ return port;
211
+ }
212
+ /**
213
+ * Resolves the effective callback port from the `--port` override, the
214
+ * server's `oauth.callbackPort` config field, or 0 (OS-assigned) as a
215
+ * final fallback.
216
+ *
217
+ * @param override - The validated `--port` override, or undefined.
218
+ * @param server - The typed downstream server configuration.
219
+ * @returns The port to bind the callback server on (0 = OS-assigned).
220
+ */
221
+ function _resolveCallbackPort(override, server) {
222
+ if (override !== undefined) {
223
+ return override;
224
+ }
225
+ return server.oauth?.callbackPort ?? 0;
226
+ }
188
227
  /**
189
228
  * Handles the `login <name>` subcommand.
190
229
  *
@@ -196,15 +235,21 @@ function _readTimeoutMs() {
196
235
  *
197
236
  * @param configPath - Absolute path to the mcp.json file.
198
237
  * @param name - Server name to authenticate.
238
+ * @param portOverride - Optional `--port` override for the local
239
+ * callback server. 0 forces an OS-assigned port; a positive integer
240
+ * binds to that exact port (overrides `oauth.callbackPort`). When
241
+ * omitted, `oauth.callbackPort` from config is used, falling back to
242
+ * an OS-assigned port.
199
243
  * @returns Human-readable confirmation message.
200
244
  * @throws If the server name is not found or is not an HTTP type.
201
245
  */
202
- export async function handleLogin(configPath, name) {
246
+ export async function handleLogin(configPath, name, portOverride) {
203
247
  const { targetServer } = await validateServerForLogin(configPath, name);
248
+ const callbackPort = _resolveCallbackPort(_validatePortOverride(portOverride), targetServer);
204
249
  const { OAuthCredentialManager } = await import('../services/oauth.js');
205
250
  const mgr = new OAuthCredentialManager(configPath, targetServer);
206
251
  const metadata = await setupOAuthClient(mgr, targetServer, name);
207
- const { authorizationCode, authResult } = await acquireAuthorizationCode(mgr, metadata, targetServer);
252
+ const { authorizationCode, authResult } = await acquireAuthorizationCode(mgr, metadata, targetServer, callbackPort);
208
253
  const { exchangeAuthorization } = await _getSdkAuth();
209
254
  const realRedirectUrl = mgr.redirectUrl;
210
255
  const tokens = await exchangeAuthorization(new URL(metadata.token_endpoint), {
@@ -34,6 +34,19 @@ export function collectEnv(value, previous) {
34
34
  export function collectStringArray(value, previous) {
35
35
  return [...previous, value];
36
36
  }
37
+ /**
38
+ * Coerces a `--port` flag value into an integer. Throws on non-numeric
39
+ * values so the user gets a clear error before any network activity.
40
+ * Range validation is deferred to the command handlers.
41
+ * @internal — Exported for tests only; not part of the public module API.
42
+ */
43
+ export function parsePort(value) {
44
+ const port = Number(value);
45
+ if (!Number.isInteger(port)) {
46
+ throw new Error(`--port must be an integer (got "${value}").`);
47
+ }
48
+ return port;
49
+ }
37
50
  /**
38
51
  * Wraps a CLI action handler with error handling.
39
52
  * On success, writes the result to stdout. On error, writes to stderr
@@ -70,6 +83,7 @@ function buildAddOptions(name, commandOrUrl, rest, options) {
70
83
  disabled: options.disabled || undefined,
71
84
  allowedTools: options.allowedTools && options.allowedTools.length > 0 ? options.allowedTools : undefined,
72
85
  disabledTools: options.disabledTools && options.disabledTools.length > 0 ? options.disabledTools : undefined,
86
+ port: options.port,
73
87
  };
74
88
  }
75
89
  function registerAddCommand(program) {
@@ -85,6 +99,7 @@ function registerAddCommand(program) {
85
99
  .option('--disabled', 'mark the server as disabled (writes "enabled": false)')
86
100
  .option('--allowed-tools <pattern>', 'glob pattern allowlisting tool names (repeatable)', collectStringArray, [])
87
101
  .option('--disabled-tools <pattern>', 'glob pattern denylisting tool names (repeatable)', collectStringArray, [])
102
+ .option('-p, --port <number>', 'fixed local OAuth callback port (HTTP only; written to oauth.callbackPort)', parsePort)
88
103
  .action(guardedAction(async (name, commandOrUrl, rest, options) => {
89
104
  const configPath = await resolveConfigPath(options.config);
90
105
  return handleAdd(configPath, buildAddOptions(name, commandOrUrl, rest, options));
@@ -125,9 +140,10 @@ function registerLoginCommand(program) {
125
140
  .command('login <name>')
126
141
  .description('Authenticate a downstream server using OAuth')
127
142
  .option('-c, --config <path>', 'path to mcp.json configuration file')
143
+ .option('-p, --port <number>', 'fixed local OAuth callback port (overrides oauth.callbackPort; 0 = OS-assigned)', parsePort)
128
144
  .action(guardedAction(async (name, options) => {
129
145
  const configPath = await resolveConfigPath(options.config);
130
- return handleLogin(configPath, name);
146
+ return handleLogin(configPath, name, options.port);
131
147
  }));
132
148
  }
133
149
  function registerLogoutCommand(program) {
@@ -6,15 +6,22 @@ import { ensureConfigDir } from './config-io.js';
6
6
  const DESCRIPTION_MAX_WIDTH = 60;
7
7
  /**
8
8
  * Truncates a description to {@link DESCRIPTION_MAX_WIDTH} characters,
9
- * appending an ellipsis when truncated. Returns an empty string for
10
- * undefined input.
9
+ * appending an ellipsis when truncated. Collapses all whitespace
10
+ * (including embedded newlines from markdown descriptions) to single
11
+ * spaces so downstream table layout stays intact. Returns an empty
12
+ * string for undefined input.
13
+ *
14
+ * @internal Exported for tests only; not part of the public module API.
11
15
  */
12
- function truncateDescription(desc) {
16
+ export function truncateDescription(desc) {
13
17
  if (!desc)
14
18
  return '';
15
- if (desc.length <= DESCRIPTION_MAX_WIDTH)
16
- return desc;
17
- return desc.slice(0, DESCRIPTION_MAX_WIDTH - 1) + '…';
19
+ // Collapse all whitespace (including embedded newlines from markdown
20
+ // descriptions) to single spaces so the table layout stays intact.
21
+ const flat = desc.replace(/\s+/g, ' ').trim();
22
+ if (flat.length <= DESCRIPTION_MAX_WIDTH)
23
+ return flat;
24
+ return flat.slice(0, DESCRIPTION_MAX_WIDTH - 1) + '…';
18
25
  }
19
26
  /**
20
27
  * Renders the tools table from the filter result.
@@ -1,16 +1,19 @@
1
1
  import { readCredentials, writeCredentials } from '../cli/config-io.js';
2
+ import { discoverAuth } from './oauth-discovery.js';
2
3
  /**
3
- * Probes a downstream HTTP server's OAuth discovery endpoint to
4
- * determine whether it advertises OAuth support. Makes a single probe
5
- * of the server's well-known authorization-server metadata.
4
+ * Probes a downstream HTTP server to determine whether it advertises OAuth
5
+ * support, following the MCP 2025-06-18 two-step flow: RFC 9728 Protected
6
+ * Resource Metadata first, then RFC 8414 Authorization Server Metadata at
7
+ * each advertised authorization server (with an origin-root fallback for
8
+ * legacy servers).
6
9
  *
7
10
  * stdio servers never support OAuth, so they short-circuit to `'none'`
8
11
  * without any network access.
9
12
  *
10
13
  * @param server - Typed downstream server config.
11
14
  * @param logger - Optional logger for diagnostic output on probe errors.
12
- * @returns `'oauth'` when metadata is advertised, `'none'` when it is
13
- * absent, or `'unknown'` on network/probe errors.
15
+ * @returns `'oauth'` when authorization-server metadata is advertised,
16
+ * `'none'` when it is absent, or `'unknown'` on network/probe errors.
14
17
  * @internal Exported for tests only; not part of the public module API.
15
18
  */
16
19
  export async function probeAuthRequirement(server, logger) {
@@ -21,9 +24,8 @@ export async function probeAuthRequirement(server, logger) {
21
24
  return 'unknown';
22
25
  }
23
26
  try {
24
- const { discoverAuthorizationServerMetadata } = await import('@modelcontextprotocol/sdk/client/auth.js');
25
- const metadata = await discoverAuthorizationServerMetadata(new URL(server.url));
26
- return metadata ? 'oauth' : 'none';
27
+ const discovered = await discoverAuth(new URL(server.url));
28
+ return discovered.serverMetadata ? 'oauth' : 'none';
27
29
  }
28
30
  catch (err) {
29
31
  logger?.error(`Failed to probe OAuth metadata for "${server.name}"`, {
@@ -194,6 +194,7 @@ function parseOauthBlock(entryContext, server) {
194
194
  ? expandEnvField(rawClientSecret, `${entryContext} oauth.clientSecret`)
195
195
  : undefined;
196
196
  const scope = rawScope !== undefined ? expandEnvField(rawScope, `${entryContext} oauth.scope`) : undefined;
197
+ const callbackPort = parseCallbackPort(oauthRaw.callbackPort, entryContext);
197
198
  const oauth = {};
198
199
  if (clientId !== undefined)
199
200
  oauth.clientId = clientId;
@@ -201,8 +202,31 @@ function parseOauthBlock(entryContext, server) {
201
202
  oauth.clientSecret = clientSecret;
202
203
  if (scope !== undefined)
203
204
  oauth.scope = scope;
205
+ if (callbackPort !== undefined)
206
+ oauth.callbackPort = callbackPort;
204
207
  return oauth;
205
208
  }
209
+ /**
210
+ * Parses and validates the optional `oauth.callbackPort` field.
211
+ *
212
+ * Accepts a JSON number or a numeric string. Must be an integer between
213
+ * 1 and 65535. No env expansion is applied (ports are not secrets).
214
+ *
215
+ * @param value - The raw `callbackPort` value from the oauth block.
216
+ * @param entryContext - Human-readable context string for error messages.
217
+ * @returns The validated port number, or undefined when absent.
218
+ * @throws If the value is present but not a valid port number.
219
+ */
220
+ function parseCallbackPort(value, entryContext) {
221
+ if (value === undefined || value === null) {
222
+ return undefined;
223
+ }
224
+ const port = typeof value === 'number' ? value : Number(value);
225
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
226
+ throw new Error(`Server "${entryContext.slice(8)}" oauth.callbackPort must be an integer between 1 and 65535`);
227
+ }
228
+ return port;
229
+ }
206
230
  /**
207
231
  * Validates the optional `enabled` field on a server entry.
208
232
  *
@@ -4,3 +4,4 @@ export { connectAndDiscover, discoverSingleServer } from './discovery.js';
4
4
  export { invokeDownstreamTool } from './invoker.js';
5
5
  export { OAuthCredentialManager } from './oauth.js';
6
6
  export { computeAuthStatus, persistAuthRequirements } from './auth-status.js';
7
+ export { discoverAuth } from './oauth-discovery.js';
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Discovers OAuth metadata for a downstream MCP server following the
3
+ * MCP 2025-06-18 authorization spec two-step flow:
4
+ *
5
+ * 1. RFC 9728 Protected Resource Metadata (PRM) at the server URL. When
6
+ * present, its `authorization_servers` array lists the AS URLs to query.
7
+ * 2. RFC 8414 / OIDC Authorization Server Metadata at each advertised AS URL.
8
+ *
9
+ * Legacy servers that publish AS metadata directly at their host root without
10
+ * PRM are still supported: when no PRM is found (or it advertises no usable
11
+ * AS), discovery falls back to the server URL and, if that URL has a path,
12
+ * its origin root.
13
+ *
14
+ * All per-candidate discovery errors are swallowed and treated as "not
15
+ * found" so a single flaky endpoint never aborts the whole flow — discovery
16
+ * falls through to the next candidate. When no metadata is found anywhere
17
+ * AND at least one candidate threw, the last error is re-thrown so callers
18
+ * can distinguish a clean "no OAuth published" (all 404s) from an actual
19
+ * server/network failure (e.g. the auth probe reports `'unknown'`, the
20
+ * login command throws a guided error).
21
+ *
22
+ * @param serverUrl - The downstream MCP server URL to discover auth for.
23
+ * @returns The discovered resource and/or server metadata plus the AS URL
24
+ * that yielded the metadata. `serverMetadata` is `undefined` when no OAuth
25
+ * endpoints could be discovered.
26
+ * @throws The last discovery error when no metadata was found and at least
27
+ * one candidate endpoint errored. Clean "not found" (all 404s) does not
28
+ * throw.
29
+ */
30
+ export async function discoverAuth(serverUrl) {
31
+ const { discoverOAuthProtectedResourceMetadata, discoverAuthorizationServerMetadata } = await import('@modelcontextprotocol/sdk/client/auth.js');
32
+ // Tracks the last error seen across all candidates so the caller can be
33
+ // notified when discovery failed entirely (vs. cleanly finding nothing).
34
+ let lastError;
35
+ // Tolerant AS discovery: any error (404-as-throw, 5xx, network) is
36
+ // recorded and treated as "not found" so the next candidate is tried.
37
+ const safeDiscoverAs = async (url) => {
38
+ try {
39
+ return await discoverAuthorizationServerMetadata(url);
40
+ }
41
+ catch (err) {
42
+ lastError = err;
43
+ return undefined;
44
+ }
45
+ };
46
+ // Step 1: RFC 9728 Protected Resource Metadata. This SDK function throws
47
+ // when no PRM is published (treated as "no PRM, fall through"), so its
48
+ // error is intentionally NOT recorded — absence of PRM is the normal
49
+ // legacy-server path, not a probe failure.
50
+ let resourceMetadata;
51
+ try {
52
+ resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
53
+ }
54
+ catch {
55
+ // No PRM published; fall through to direct AS discovery below.
56
+ }
57
+ // Step 2: AS metadata at each advertised authorization server.
58
+ if (resourceMetadata?.authorization_servers?.length) {
59
+ for (const asUrlString of resourceMetadata.authorization_servers) {
60
+ const asUrl = new URL(asUrlString);
61
+ const metadata = await safeDiscoverAs(asUrl);
62
+ if (metadata) {
63
+ return { resourceMetadata, serverMetadata: metadata, authorizationServerUrl: asUrl };
64
+ }
65
+ }
66
+ }
67
+ // Legacy fallback: direct AS discovery at the server URL, then its origin
68
+ // root (for servers that host AS metadata at the root with the MCP endpoint
69
+ // on a subpath and no PRM).
70
+ const candidates = [serverUrl];
71
+ if (serverUrl.pathname !== '/') {
72
+ candidates.push(new URL(serverUrl.origin));
73
+ }
74
+ for (const candidate of candidates) {
75
+ const metadata = await safeDiscoverAs(candidate);
76
+ if (metadata) {
77
+ return { resourceMetadata, serverMetadata: metadata, authorizationServerUrl: candidate };
78
+ }
79
+ }
80
+ // No metadata found anywhere. If any candidate actually errored (vs. a
81
+ // clean 404), surface that so callers can report a probe failure rather
82
+ // than a misleading "no OAuth supported".
83
+ if (lastError !== undefined) {
84
+ throw lastError;
85
+ }
86
+ return { resourceMetadata, serverMetadata: undefined, authorizationServerUrl: serverUrl };
87
+ }
@@ -1,4 +1,16 @@
1
1
  import { readCredentials, writeCredentials, removeCredentials } from '../cli/config-io.js';
2
+ /**
3
+ * The OAuth redirect callback path served by the temporary local HTTP
4
+ * server started during `login`. The full redirect URI is
5
+ * `http://localhost:<port>/mcp-compress-router/oauth-callback`, where
6
+ * `<port>` is assigned by the OS. Register this path (on `localhost`,
7
+ * any port) with OAuth providers that require a pre-registered client.
8
+ *
9
+ * @internal Exported for tests only; not part of the public module API.
10
+ * The constant is consumed internally by `redirectUrl`; tests import
11
+ * it directly to avoid hardcoding the path string.
12
+ */
13
+ export const OAUTH_CALLBACK_PATH = '/mcp-compress-router/oauth-callback';
2
14
  /**
3
15
  * Implements OAuthClientProvider backed by credentials.json credential storage.
4
16
  *
@@ -29,7 +41,7 @@ export class OAuthCredentialManager {
29
41
  get redirectUrl() {
30
42
  // Return the callback URL with the actual port assigned by the OS.
31
43
  // Falls back to port 0 until setActualPort() is called by login-command.
32
- return `http://localhost:${this._actualPort}/callback`;
44
+ return `http://localhost:${this._actualPort}${OAUTH_CALLBACK_PATH}`;
33
45
  }
34
46
  get clientMetadata() {
35
47
  return {
@@ -55,7 +55,5 @@ export function createGetToolSchemaHandler(catalog, logger) {
55
55
  */
56
56
  export function buildGetToolSchemaDescription(catalog) {
57
57
  const compact = renderCompactCatalog(catalog.servers);
58
- return ('Get the JSON schema for one or more tools from a connected MCP server.\n\n' +
59
- 'Available MCP servers and their tools:\n\n' +
60
- compact);
58
+ return 'Get the JSON schema for one or more tools from a connected MCP server.\n\n' + compact;
61
59
  }
@@ -5,20 +5,29 @@
5
5
  * Format:
6
6
  * ## {server name}
7
7
  * {description (optional)}
8
+ *
9
+ * Available tools:
8
10
  * {tool1}, {tool2}, ...
9
11
  *
12
+ * When a server has no tools, only the header (and optional
13
+ * description) is rendered.
14
+ *
10
15
  * @param servers - The catalog server entries.
11
16
  * @returns Compact catalog text.
12
17
  */
13
18
  export function renderCompactCatalog(servers) {
14
- const lines = [];
19
+ const blocks = [];
15
20
  for (const server of servers) {
16
- lines.push(`## ${server.name}`);
21
+ const lines = [`## ${server.name}`];
17
22
  if (server.description) {
18
23
  lines.push(server.description);
19
24
  }
20
- lines.push(server.tools.map((t) => t.name).join(', '));
21
- lines.push(''); // blank line between servers
25
+ if (server.tools.length > 0) {
26
+ lines.push('');
27
+ lines.push('Available tools:');
28
+ lines.push(server.tools.map((t) => t.name).join(', '));
29
+ }
30
+ blocks.push(lines.join('\n'));
22
31
  }
23
- return lines.join('\n').trimEnd();
32
+ return blocks.join('\n\n');
24
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",