mcp-compress-router 3.0.0 → 3.0.1

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,174 @@
1
+ /**
2
+ * HTML pages served by the temporary loopback OAuth callback server.
3
+ *
4
+ * The pages are self-contained — inline CSS and inline SVG, no external
5
+ * requests — and carry the `mcp-compress-router` name, so the browser
6
+ * window that opens during `login` is identifiable as this tool. Every
7
+ * interpolated value is HTML-escaped, so OAuth error text reflected
8
+ * from the callback query cannot inject markup.
9
+ */
10
+ /**
11
+ * Styles shared by every callback page. A single constant keeps the
12
+ * pages self-contained (no external stylesheet) and defines the layout
13
+ * once for both variants; the error variant only swaps the accent
14
+ * colors.
15
+ */
16
+ const PAGE_STYLES = ` :root {
17
+ color-scheme: light dark;
18
+ --bg: #f4f5f7;
19
+ --card-bg: #ffffff;
20
+ --card-border: #e3e5e8;
21
+ --text: #1b1f24;
22
+ --muted: #656d76;
23
+ --code-bg: #f0f1f3;
24
+ --accent: #1a7f37;
25
+ --accent-soft: #dafbe1;
26
+ }
27
+ @media (prefers-color-scheme: dark) {
28
+ :root {
29
+ --bg: #0d1117;
30
+ --card-bg: #161b22;
31
+ --card-border: #30363d;
32
+ --text: #e6edf3;
33
+ --muted: #8b949e;
34
+ --code-bg: #21262d;
35
+ --accent: #3fb950;
36
+ --accent-soft: #12261e;
37
+ }
38
+ }
39
+ * {
40
+ box-sizing: border-box;
41
+ }
42
+ body {
43
+ margin: 0;
44
+ min-height: 100vh;
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ padding: 24px;
49
+ background: var(--bg);
50
+ color: var(--text);
51
+ font-family:
52
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
53
+ sans-serif;
54
+ -webkit-font-smoothing: antialiased;
55
+ }
56
+ .card {
57
+ width: 100%;
58
+ max-width: 420px;
59
+ padding: 40px 32px 28px;
60
+ background: var(--card-bg);
61
+ border: 1px solid var(--card-border);
62
+ border-radius: 14px;
63
+ box-shadow: 0 8px 24px rgb(0 0 0 / 8%);
64
+ text-align: center;
65
+ }
66
+ .card.error {
67
+ --accent: #cf222e;
68
+ --accent-soft: #ffebe9;
69
+ }
70
+ @media (prefers-color-scheme: dark) {
71
+ .card.error {
72
+ --accent: #f85149;
73
+ --accent-soft: #2d1416;
74
+ }
75
+ }
76
+ .icon {
77
+ display: block;
78
+ width: 56px;
79
+ height: 56px;
80
+ margin: 0 auto 20px;
81
+ }
82
+ h1 {
83
+ margin: 0 0 12px;
84
+ font-size: 20px;
85
+ font-weight: 600;
86
+ letter-spacing: -0.01em;
87
+ }
88
+ p {
89
+ margin: 0;
90
+ font-size: 14px;
91
+ line-height: 1.5;
92
+ color: var(--muted);
93
+ }
94
+ p + p {
95
+ margin-top: 12px;
96
+ }
97
+ .detail code {
98
+ display: inline-block;
99
+ padding: 3px 8px;
100
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
101
+ font-size: 13px;
102
+ color: var(--text);
103
+ background: var(--code-bg);
104
+ border-radius: 6px;
105
+ }
106
+ footer {
107
+ margin-top: 28px;
108
+ padding-top: 16px;
109
+ border-top: 1px solid var(--card-border);
110
+ font-size: 12px;
111
+ color: var(--muted);
112
+ }`;
113
+ /**
114
+ * Renders one self-contained callback page.
115
+ *
116
+ * @param options - Page content and variant.
117
+ * @returns A complete HTML document.
118
+ */
119
+ export function renderCallbackPage(options) {
120
+ const title = `${escapeHtml(options.heading)} — mcp-compress-router`;
121
+ const detail = options.detail
122
+ ? `\n <p class="detail"><code>${escapeHtml(options.detail)}</code></p>`
123
+ : '';
124
+ return `<!doctype html>
125
+ <html lang="en">
126
+ <head>
127
+ <meta charset="utf-8" />
128
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
129
+ <title>${title}</title>
130
+ <style>
131
+ ${PAGE_STYLES}
132
+ </style>
133
+ </head>
134
+ <body>
135
+ <main class="card ${options.kind}">
136
+ ${statusIcon(options.kind)}
137
+ <h1>${escapeHtml(options.heading)}</h1>
138
+ <p>${escapeHtml(options.message)}</p>${detail}
139
+ <footer>mcp-compress-router</footer>
140
+ </main>
141
+ </body>
142
+ </html>
143
+ `;
144
+ }
145
+ /**
146
+ * Builds the inline SVG status icon for a page variant.
147
+ *
148
+ * @param kind - Page variant.
149
+ * @returns The SVG markup (static, contains no user input).
150
+ */
151
+ function statusIcon(kind) {
152
+ const path = kind === 'success'
153
+ ? '<path d="M19 29l7 7 12-14" fill="none" stroke="var(--accent)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />'
154
+ : '<path d="M21 21l14 14M35 21L21 35" fill="none" stroke="var(--accent)" stroke-width="4" stroke-linecap="round" />';
155
+ return `<svg class="icon" viewBox="0 0 56 56" aria-hidden="true" focusable="false">
156
+ <circle cx="28" cy="28" r="28" fill="var(--accent-soft)" />
157
+ ${path}
158
+ </svg>`;
159
+ }
160
+ /**
161
+ * Escapes the HTML special characters in a reflected value, so an OAuth
162
+ * error string cannot inject markup into a callback page.
163
+ *
164
+ * @param value - The raw value to escape.
165
+ * @returns The escaped value.
166
+ */
167
+ function escapeHtml(value) {
168
+ return value
169
+ .replaceAll('&', '&amp;')
170
+ .replaceAll('<', '&lt;')
171
+ .replaceAll('>', '&gt;')
172
+ .replaceAll('"', '&quot;')
173
+ .replaceAll("'", '&#39;');
174
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import * as http from 'node:http';
3
+ import { renderCallbackPage } from './login-callback-page.js';
3
4
  /**
4
5
  * Starts a temporary loopback HTTP server on `callbackPort`, runs the
5
6
  * authorization-code flow through `beginAuthorization`, and waits for the
@@ -90,76 +91,136 @@ function validateCallback(flow, url) {
90
91
  return undefined;
91
92
  }
92
93
  /**
93
- * Escapes the HTML special characters in a reflected value, so an OAuth
94
- * error string cannot inject markup into the callback page.
94
+ * Response headers for every callback page: self-contained HTML plus a
95
+ * strict CSP. The pages load no scripts and no external resources; the
96
+ * policy is defense in depth behind the renderer's HTML escaping.
97
+ */
98
+ const PAGE_HEADERS = {
99
+ 'Content-Type': 'text/html; charset=utf-8',
100
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
101
+ };
102
+ /**
103
+ * Writes one callback page response.
95
104
  *
96
- * @param value - The raw value to escape.
97
- * @returns The escaped value.
105
+ * @param res - The server response.
106
+ * @param status - HTTP status code.
107
+ * @param html - The complete page document.
98
108
  */
99
- function escapeHtml(value) {
100
- return value
101
- .replaceAll('&', '&amp;')
102
- .replaceAll('<', '&lt;')
103
- .replaceAll('>', '&gt;')
104
- .replaceAll('"', '&quot;')
105
- .replaceAll("'", '&#39;');
109
+ function sendPage(res, status, html) {
110
+ res.writeHead(status, PAGE_HEADERS);
111
+ res.end(html);
106
112
  }
107
113
  /**
108
- * Creates the HTTP request listener for the temporary callback server.
114
+ * Responds with the failure page for a callback that failed validation
115
+ * (a stray or forged request that must not settle the flow).
116
+ *
117
+ * @param res - The server response.
118
+ */
119
+ function respondInvalidCallback(res) {
120
+ sendPage(res, 400, renderCallbackPage({
121
+ kind: 'error',
122
+ heading: 'Authorization failed',
123
+ message: 'Invalid OAuth callback.',
124
+ }));
125
+ }
126
+ /**
127
+ * Responds with the failure page for an authorization error, showing the
128
+ * OAuth error code and, when provided, the server's description.
109
129
  *
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.
130
+ * @param res - The server response.
131
+ * @param error - The OAuth error code from the callback.
132
+ * @param description - The OAuth `error_description`, or null.
133
+ */
134
+ function respondAuthorizationError(res, error, description) {
135
+ sendPage(res, 400, renderCallbackPage({
136
+ kind: 'error',
137
+ heading: 'Authorization failed',
138
+ message: description ?? 'The authorization server rejected the request.',
139
+ detail: error,
140
+ }));
141
+ }
142
+ /**
143
+ * Responds with the success page after a valid callback.
144
+ *
145
+ * @param res - The server response.
146
+ */
147
+ function respondSuccess(res) {
148
+ sendPage(res, 200, renderCallbackPage({
149
+ kind: 'success',
150
+ heading: 'Authorization successful',
151
+ message: 'You can close this window and return to your terminal.',
152
+ }));
153
+ }
154
+ /**
155
+ * Settles the flow with a failure: clears the callback timeout, closes
156
+ * the temporary server, and rejects the pending promise.
157
+ *
158
+ * @param flow - Callback flow state for this login attempt.
159
+ * @param tempServer - The temporary callback server (closed on failure).
160
+ * @param reject - Promise reject function.
161
+ * @param message - The error message for the rejection.
162
+ */
163
+ function failFlow(flow, tempServer, reject, message) {
164
+ if (flow.timeoutHandle.current)
165
+ clearTimeout(flow.timeoutHandle.current);
166
+ tempServer.close();
167
+ reject(new Error(message));
168
+ }
169
+ /**
170
+ * Handles one callback request: validates the CSRF `state` echoed by the
171
+ * authorization server and, when present, the RFC 9207 `iss` issuer
172
+ * parameter before acting on the callback. A callback that fails
173
+ * validation is ignored: it gets a 400 response but never settles the
174
+ * flow, so a stray loopback request cannot abort a login whose
175
+ * legitimate callback is still on its way.
117
176
  *
118
177
  * @param flow - Callback flow state for this login attempt.
119
178
  * @param tempServer - The temporary callback server (closed on completion).
120
179
  * @param resolve - Promise resolve function (authorization code).
121
180
  * @param reject - Promise reject function.
122
- * @returns An HTTP request listener.
181
+ * @param req - The incoming request.
182
+ * @param res - The server response.
123
183
  */
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');
184
+ function handleCallbackRequest(flow, tempServer, resolve, reject, req, res) {
185
+ const url = new URL(req.url, `http://127.0.0.1`);
186
+ const error = url.searchParams.get('error');
187
+ const code = url.searchParams.get('code');
188
+ // Only authorization responses (a code or an error) carry a state to
189
+ // validate; anything else is not a callback.
190
+ if (error !== null || code !== null) {
191
+ const validationError = validateCallback(flow, url);
192
+ if (validationError) {
193
+ respondInvalidCallback(res);
154
194
  return;
155
195
  }
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
- };
196
+ }
197
+ if (error !== null) {
198
+ respondAuthorizationError(res, error, url.searchParams.get('error_description'));
199
+ failFlow(flow, tempServer, reject, `Authorization failed: ${error}`);
200
+ return;
201
+ }
202
+ if (code === null) {
203
+ res.writeHead(404);
204
+ res.end('Not found');
205
+ return;
206
+ }
207
+ if (flow.timeoutHandle.current)
208
+ clearTimeout(flow.timeoutHandle.current);
209
+ respondSuccess(res);
210
+ tempServer.close();
211
+ resolve(code);
212
+ }
213
+ /**
214
+ * Creates the HTTP request listener for the temporary callback server.
215
+ *
216
+ * @param flow - Callback flow state for this login attempt.
217
+ * @param tempServer - The temporary callback server (closed on completion).
218
+ * @param resolve - Promise resolve function (authorization code).
219
+ * @param reject - Promise reject function.
220
+ * @returns An HTTP request listener.
221
+ */
222
+ function makeCallbackHandler(flow, tempServer, resolve, reject) {
223
+ return (req, res) => handleCallbackRequest(flow, tempServer, resolve, reject, req, res);
163
224
  }
164
225
  /**
165
226
  * Listen callback: records the bound port, prepares the authorization
@@ -59,9 +59,11 @@ async function _getOpenBrowser() {
59
59
  *
60
60
  * @param targetServer - Typed downstream server configuration.
61
61
  * @param name - Server name (for error messages).
62
- * @returns The discovered OAuth metadata and whether the authorization
62
+ * @returns The discovered OAuth metadata, whether the authorization
63
63
  * server commits to the RFC 9207 `iss` parameter (read from the raw
64
- * metadata document, so it also covers OpenID Connect discovery).
64
+ * metadata document, so it also covers OpenID Connect discovery),
65
+ * and the RFC 9728 Protected Resource Metadata when the server
66
+ * publishes it (used to derive the RFC 8707 `resource` indicator).
65
67
  * @throws If the server does not expose OAuth metadata.
66
68
  */
67
69
  async function discoverOAuthMetadata(targetServer, name) {
@@ -74,6 +76,7 @@ async function discoverOAuthMetadata(targetServer, name) {
74
76
  return {
75
77
  metadata: metadata,
76
78
  requireIssuer: discovered.authorizationResponseIssParameterSupported,
79
+ resourceMetadata: discovered.resourceMetadata,
77
80
  };
78
81
  }
79
82
  /**
@@ -147,9 +150,12 @@ async function registerClientIfNeeded(mgr, metadata, name) {
147
150
  * @param targetServer - Typed downstream server configuration.
148
151
  * @param name - Server name (for error messages).
149
152
  * @param state - CSRF state generated for this login attempt.
153
+ * @param resource - RFC 8707 resource indicator selected from the
154
+ * server's Protected Resource Metadata, or undefined when the server
155
+ * publishes none (the SDK then omits the `resource` parameter).
150
156
  * @returns The `startAuthorization` result.
151
157
  */
152
- async function beginAuthorization(mgr, metadata, targetServer, name, state) {
158
+ async function beginAuthorization(mgr, metadata, targetServer, name, state, resource) {
153
159
  await registerClientIfNeeded(mgr, metadata, name);
154
160
  const { startAuthorization } = await _getSdkAuth();
155
161
  return startAuthorization(new URL(metadata.authorization_endpoint), {
@@ -158,6 +164,7 @@ async function beginAuthorization(mgr, metadata, targetServer, name, state) {
158
164
  redirectUrl: mgr.redirectUrl,
159
165
  scope: targetServer.oauth?.scope,
160
166
  state,
167
+ resource,
161
168
  });
162
169
  }
163
170
  /**
@@ -202,7 +209,9 @@ function _resolveCallbackPort(override, server) {
202
209
  * temporary loopback callback server, registers the client (when
203
210
  * needed) with the portless loopback callback URI, opens a browser,
204
211
  * handles the redirect callback, exchanges the authorization code for
205
- * tokens, and persists them in credentials.json.
212
+ * tokens, and persists them in credentials.json. The authorization and
213
+ * token requests carry the RFC 8707 `resource` indicator derived from
214
+ * the server's Protected Resource Metadata when it publishes one.
206
215
  *
207
216
  * @param configPath - Absolute path to the mcp.json file.
208
217
  * @param name - Server name to authenticate.
@@ -219,16 +228,21 @@ export async function handleLogin(configPath, name, portOverride) {
219
228
  const callbackPort = _resolveCallbackPort(_validatePortOverride(portOverride), targetServer);
220
229
  const { OAuthCredentialManager } = await import('../services/oauth.js');
221
230
  const mgr = new OAuthCredentialManager(configPath, targetServer);
222
- const { metadata, requireIssuer } = await discoverOAuthMetadata(targetServer, name);
231
+ const { metadata, requireIssuer, resourceMetadata } = await discoverOAuthMetadata(targetServer, name);
232
+ const { selectResourceURL, exchangeAuthorization } = await _getSdkAuth();
233
+ // RFC 8707 resource indicator, selected from the server's Protected
234
+ // Resource Metadata the same way the SDK's auth() flow selects it.
235
+ // Undefined when the server publishes no such metadata: the SDK then
236
+ // omits the parameter, matching the runtime transport path.
237
+ const resource = await selectResourceURL(new URL(targetServer.url), mgr, resourceMetadata);
223
238
  const { authorizationCode, authResult } = await acquireAuthorizationCode({
224
239
  mgr,
225
240
  callbackPort,
226
241
  openBrowser: await _getOpenBrowser(),
227
242
  expectedIssuer: metadata.issuer,
228
243
  requireIssuer,
229
- beginAuthorization: (state) => beginAuthorization(mgr, metadata, targetServer, name, state),
244
+ beginAuthorization: (state) => beginAuthorization(mgr, metadata, targetServer, name, state, resource),
230
245
  });
231
- const { exchangeAuthorization } = await _getSdkAuth();
232
246
  const realRedirectUrl = mgr.redirectUrl;
233
247
  const tokens = await exchangeAuthorization(new URL(metadata.token_endpoint), {
234
248
  metadata,
@@ -236,6 +250,7 @@ export async function handleLogin(configPath, name, portOverride) {
236
250
  authorizationCode,
237
251
  codeVerifier: authResult.codeVerifier,
238
252
  redirectUri: realRedirectUrl,
253
+ resource,
239
254
  });
240
255
  await mgr.saveTokens(tokens);
241
256
  // Best-effort: discover tools and save to cache so the next router
@@ -1,4 +1,4 @@
1
- import { refreshAuthorization, } from '@modelcontextprotocol/sdk/client/auth.js';
1
+ import { refreshAuthorization, selectResourceURL, } from '@modelcontextprotocol/sdk/client/auth.js';
2
2
  import { readCredentials, writeCredentials, removeCredentials } from '../cli/config-io.js';
3
3
  import { discoverAuth } from './oauth-discovery.js';
4
4
  import { GuidedAuthError } from './index.js';
@@ -337,16 +337,20 @@ export class OAuthCredentialManager {
337
337
  * Performs the token refresh: discovers the authorization server
338
338
  * metadata, calls the SDK's `refreshAuthorization`, and persists the
339
339
  * refreshed tokens via {@link saveTokens} (which recomputes
340
- * `expires_at`). Errors are logged (when a logger is supplied) and
341
- * swallowed — proactive refresh is best-effort; the SDK's 401 path
342
- * handles terminal failures.
340
+ * `expires_at`). The request carries the RFC 8707 `resource`
341
+ * indicator selected from the server's Protected Resource Metadata —
342
+ * the same indicator `login` sent — so a provider that binds tokens
343
+ * to a specific resource accepts the refresh. Errors are logged
344
+ * (when a logger is supplied) and swallowed — proactive refresh is
345
+ * best-effort; the SDK's 401 path handles terminal failures.
343
346
  */
344
347
  async _refreshTokens(refreshToken, logger) {
345
348
  try {
346
349
  if (!this._server.url) {
347
350
  return;
348
351
  }
349
- const discovered = await discoverAuth(new URL(this._server.url));
352
+ const serverUrl = new URL(this._server.url);
353
+ const discovered = await discoverAuth(serverUrl);
350
354
  if (!discovered.serverMetadata) {
351
355
  return;
352
356
  }
@@ -354,10 +358,12 @@ export class OAuthCredentialManager {
354
358
  if (!clientInformation) {
355
359
  return;
356
360
  }
361
+ const resource = await selectResourceURL(serverUrl, this, discovered.resourceMetadata);
357
362
  const newTokens = await refreshAuthorization(discovered.authorizationServerUrl, {
358
363
  metadata: discovered.serverMetadata,
359
364
  clientInformation,
360
365
  refreshToken,
366
+ resource,
361
367
  });
362
368
  await this.saveTokens(newTokens);
363
369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Compress all connected MCP servers into a single router MCP to save tokens",
5
5
  "license": "MIT",
6
6
  "type": "module",