mcp-compress-router 1.1.0 → 1.3.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 +100 -0
- package/build/cli/add-command.js +27 -7
- package/build/cli/login-command.js +56 -11
- package/build/cli/register-commands.js +17 -1
- package/build/cli/tools-command.js +13 -6
- package/build/index.js +4 -1
- package/build/services/auth-status.js +10 -8
- package/build/services/config.js +24 -0
- package/build/services/index.js +1 -0
- package/build/services/oauth-discovery.js +87 -0
- package/build/services/oauth.js +13 -1
- package/build/tools/get-tool-schema.js +1 -3
- package/build/utils/text-format.js +14 -5
- package/package.json +1 -1
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
|
package/build/cli/add-command.js
CHANGED
|
@@ -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
|
|
111
|
-
* flow
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
-
|
|
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
|
|
127
|
-
hasOAuth =
|
|
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 {
|
|
52
|
+
const { registerClient } = await _getSdkAuth();
|
|
52
53
|
const serverUrl = new URL(targetServer.url);
|
|
53
|
-
const
|
|
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
|
-
|
|
58
|
-
|
|
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(
|
|
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.
|
|
10
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
package/build/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import dotenv from 'dotenv';
|
|
5
6
|
import { Command } from 'commander';
|
|
6
7
|
import { resolveConfigDir } from './services/index.js';
|
|
7
8
|
import { Logger } from './utils/index.js';
|
|
8
9
|
import { registerAllCommands } from './cli/register-commands.js';
|
|
10
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
9
11
|
async function main() {
|
|
10
12
|
// Load .env from the config directory before any config resolution
|
|
11
13
|
// or env var expansion. `quiet` suppresses dotenv's startup log line.
|
|
@@ -16,7 +18,8 @@ async function main() {
|
|
|
16
18
|
const program = new Command();
|
|
17
19
|
program
|
|
18
20
|
.name('mcp-compress-router')
|
|
19
|
-
.description('Compress all connected MCP servers into a single router MCP')
|
|
21
|
+
.description('Compress all connected MCP servers into a single router MCP')
|
|
22
|
+
.version(version);
|
|
20
23
|
registerAllCommands(program);
|
|
21
24
|
await program.parseAsync();
|
|
22
25
|
}
|
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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
|
|
25
|
-
|
|
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}"`, {
|
package/build/services/config.js
CHANGED
|
@@ -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
|
*
|
package/build/services/index.js
CHANGED
|
@@ -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
|
+
}
|
package/build/services/oauth.js
CHANGED
|
@@ -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}
|
|
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
|
|
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
|
|
19
|
+
const blocks = [];
|
|
15
20
|
for (const server of servers) {
|
|
16
|
-
lines
|
|
21
|
+
const lines = [`## ${server.name}`];
|
|
17
22
|
if (server.description) {
|
|
18
23
|
lines.push(server.description);
|
|
19
24
|
}
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
32
|
+
return blocks.join('\n\n');
|
|
24
33
|
}
|