@metalabdesign/mcp-client 1.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +243 -0
  3. package/dist/bin/cli.d.ts +14 -0
  4. package/dist/bin/cli.d.ts.map +1 -0
  5. package/dist/bin/cli.js +216 -0
  6. package/dist/bin/cli.js.map +1 -0
  7. package/dist/bin/metalab-mcp-config.d.ts +9 -0
  8. package/dist/bin/metalab-mcp-config.d.ts.map +1 -0
  9. package/dist/bin/metalab-mcp-config.js +224 -0
  10. package/dist/bin/metalab-mcp-config.js.map +1 -0
  11. package/dist/bin/metalab-mcp.d.ts +14 -0
  12. package/dist/bin/metalab-mcp.d.ts.map +1 -0
  13. package/dist/bin/metalab-mcp.js +242 -0
  14. package/dist/bin/metalab-mcp.js.map +1 -0
  15. package/dist/config/aws-sso.d.ts +21 -0
  16. package/dist/config/aws-sso.d.ts.map +1 -0
  17. package/dist/config/aws-sso.js +67 -0
  18. package/dist/config/aws-sso.js.map +1 -0
  19. package/dist/config/defaults.d.ts +14 -0
  20. package/dist/config/defaults.d.ts.map +1 -0
  21. package/dist/config/defaults.js +20 -0
  22. package/dist/config/defaults.js.map +1 -0
  23. package/dist/config/index.d.ts +5 -0
  24. package/dist/config/index.d.ts.map +1 -0
  25. package/dist/config/index.js +3 -0
  26. package/dist/config/index.js.map +1 -0
  27. package/dist/index.d.ts +27 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +30 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/oauth.d.ts +34 -0
  32. package/dist/oauth.d.ts.map +1 -0
  33. package/dist/oauth.js +401 -0
  34. package/dist/oauth.js.map +1 -0
  35. package/dist/proxy.d.ts +39 -0
  36. package/dist/proxy.d.ts.map +1 -0
  37. package/dist/proxy.js +203 -0
  38. package/dist/proxy.js.map +1 -0
  39. package/dist/storage.d.ts +15 -0
  40. package/dist/storage.d.ts.map +1 -0
  41. package/dist/storage.js +64 -0
  42. package/dist/storage.js.map +1 -0
  43. package/dist/types.d.ts +78 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +40 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/utils.d.ts +42 -0
  48. package/dist/utils.d.ts.map +1 -0
  49. package/dist/utils.js +89 -0
  50. package/dist/utils.js.map +1 -0
  51. package/package.json +51 -0
  52. package/src/bin/cli.ts +242 -0
  53. package/src/bin/metalab-mcp-config.ts +262 -0
  54. package/src/bin/metalab-mcp.ts +284 -0
  55. package/src/config/aws-sso.ts +78 -0
  56. package/src/config/defaults.ts +26 -0
  57. package/src/config/index.ts +8 -0
  58. package/src/index.ts +54 -0
  59. package/src/oauth.ts +540 -0
  60. package/src/proxy.ts +274 -0
  61. package/src/storage.ts +81 -0
  62. package/src/types.ts +79 -0
  63. package/src/utils.ts +115 -0
  64. package/tsconfig.json +25 -0
package/src/oauth.ts ADDED
@@ -0,0 +1,540 @@
1
+ /**
2
+ * OAuth 2.1 implementation with PKCE
3
+ * Handles metadata discovery, client registration, authorization flow, token management
4
+ */
5
+
6
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
7
+ import type { Logger } from './utils.js';
8
+ import { generateCodeChallenge, generateCodeVerifier, generateState, openBrowser } from './utils.js';
9
+ import type { TokenStorage } from './storage.js';
10
+ import type {
11
+ OAuthClientInfo,
12
+ OAuthServerMetadata,
13
+ OAuthTokens,
14
+ ProxyConfig,
15
+ } from './types.js';
16
+ import { OAuthError } from './types.js';
17
+
18
+ // Constants
19
+ const AUTH_TIMEOUT_MS = 300000; // 5 minutes
20
+ const TOKEN_REFRESH_BUFFER_MS = 60000; // Refresh 1 minute before expiry
21
+
22
+ // ==================== HTTP Utilities ====================
23
+
24
+ interface TokenResponse {
25
+ access_token: string;
26
+ token_type: string;
27
+ expires_in?: number;
28
+ refresh_token?: string;
29
+ scope?: string;
30
+ }
31
+
32
+ interface RegistrationResponse {
33
+ client_id: string;
34
+ client_secret?: string;
35
+ redirect_uris: string[];
36
+ }
37
+
38
+ // ==================== Callback Server ====================
39
+
40
+ interface PendingCallback {
41
+ resolve: (result: { code: string } | { error: string }) => void;
42
+ timeout: NodeJS.Timeout;
43
+ }
44
+
45
+ function createSuccessPage(): string {
46
+ return `<!DOCTYPE html>
47
+ <html>
48
+ <head>
49
+ <title>Authorization Complete</title>
50
+ <style>
51
+ body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
52
+ .card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
53
+ h1 { color: #22c55e; margin-bottom: 0.5rem; }
54
+ p { color: #666; }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <div class="card">
59
+ <h1>✓ Authorization Complete</h1>
60
+ <p>You can close this window and return to your terminal.</p>
61
+ </div>
62
+ </body>
63
+ </html>`;
64
+ }
65
+
66
+ function createErrorPage(error: string): string {
67
+ const escaped = error
68
+ .replace(/&/g, '&amp;')
69
+ .replace(/</g, '&lt;')
70
+ .replace(/>/g, '&gt;')
71
+ .replace(/"/g, '&quot;');
72
+
73
+ return `<!DOCTYPE html>
74
+ <html>
75
+ <head>
76
+ <title>Authorization Failed</title>
77
+ <style>
78
+ body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
79
+ .card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
80
+ h1 { color: #ef4444; margin-bottom: 0.5rem; }
81
+ p { color: #666; }
82
+ .error { background: #fef2f2; padding: 1rem; border-radius: 4px; color: #991b1b; margin-top: 1rem; }
83
+ </style>
84
+ </head>
85
+ <body>
86
+ <div class="card">
87
+ <h1>✗ Authorization Failed</h1>
88
+ <p>Something went wrong during authorization.</p>
89
+ <div class="error">${escaped}</div>
90
+ </div>
91
+ </body>
92
+ </html>`;
93
+ }
94
+
95
+ class CallbackServer {
96
+ private server: Server | null = null;
97
+ private pendingCallbacks = new Map<string, PendingCallback>();
98
+ private callbackPath: string;
99
+
100
+ constructor(callbackPath: string) {
101
+ this.callbackPath = callbackPath;
102
+ }
103
+
104
+ async start(port: number): Promise<void> {
105
+ if (this.server) return;
106
+
107
+ return new Promise((resolve, reject) => {
108
+ this.server = createServer((req, res) => this.handleRequest(req, res));
109
+ this.server.on('error', reject);
110
+ this.server.listen(port, '127.0.0.1', () => resolve());
111
+ });
112
+ }
113
+
114
+ private handleRequest(req: IncomingMessage, res: ServerResponse): void {
115
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
116
+
117
+ if (url.pathname !== this.callbackPath) {
118
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
119
+ res.end('Not Found');
120
+ return;
121
+ }
122
+
123
+ const code = url.searchParams.get('code');
124
+ const state = url.searchParams.get('state');
125
+ const error = url.searchParams.get('error');
126
+ const errorDescription = url.searchParams.get('error_description');
127
+
128
+ if (!state) {
129
+ res.writeHead(400, { 'Content-Type': 'text/html' });
130
+ res.end(createErrorPage('Missing state parameter'));
131
+ return;
132
+ }
133
+
134
+ const pending = this.pendingCallbacks.get(state);
135
+ if (!pending) {
136
+ res.writeHead(400, { 'Content-Type': 'text/html' });
137
+ res.end(createErrorPage('Invalid or expired state'));
138
+ return;
139
+ }
140
+
141
+ clearTimeout(pending.timeout);
142
+ this.pendingCallbacks.delete(state);
143
+
144
+ if (error) {
145
+ const errorMessage = errorDescription ?? error;
146
+ res.writeHead(200, { 'Content-Type': 'text/html' });
147
+ res.end(createErrorPage(errorMessage));
148
+ pending.resolve({ error: errorMessage });
149
+ return;
150
+ }
151
+
152
+ if (!code) {
153
+ res.writeHead(400, { 'Content-Type': 'text/html' });
154
+ res.end(createErrorPage('Missing authorization code'));
155
+ pending.resolve({ error: 'Missing authorization code' });
156
+ return;
157
+ }
158
+
159
+ res.writeHead(200, { 'Content-Type': 'text/html' });
160
+ res.end(createSuccessPage());
161
+ pending.resolve({ code });
162
+ }
163
+
164
+ waitForCallback(state: string, timeoutMs: number): Promise<{ code: string } | { error: string }> {
165
+ return new Promise((resolve) => {
166
+ const timeout = setTimeout(() => {
167
+ this.pendingCallbacks.delete(state);
168
+ resolve({ error: 'Authorization timed out' });
169
+ }, timeoutMs);
170
+
171
+ this.pendingCallbacks.set(state, { resolve, timeout });
172
+ });
173
+ }
174
+
175
+ async stop(): Promise<void> {
176
+ if (!this.server) return;
177
+
178
+ for (const [state, pending] of this.pendingCallbacks) {
179
+ clearTimeout(pending.timeout);
180
+ pending.resolve({ error: 'Server stopped' });
181
+ this.pendingCallbacks.delete(state);
182
+ }
183
+
184
+ return new Promise((resolve) => {
185
+ this.server?.close(() => {
186
+ this.server = null;
187
+ resolve();
188
+ });
189
+ });
190
+ }
191
+ }
192
+
193
+ // ==================== OAuth Manager ====================
194
+
195
+ interface ClientRegistration {
196
+ metadata: OAuthServerMetadata;
197
+ client: OAuthClientInfo;
198
+ }
199
+
200
+ export class OAuthManager {
201
+ private clientRegistration: ClientRegistration | null = null;
202
+ private callbackServer: CallbackServer;
203
+ private readonly callbackPort: number;
204
+
205
+ constructor(
206
+ private readonly config: ProxyConfig,
207
+ private readonly storage: TokenStorage,
208
+ private readonly logger: Logger
209
+ ) {
210
+ const callbackUrl = new URL(config.callbackUrl);
211
+ this.callbackServer = new CallbackServer(callbackUrl.pathname);
212
+ // Extract port from callback URL, default to 9876 if not specified
213
+ this.callbackPort = callbackUrl.port ? Number.parseInt(callbackUrl.port, 10) : 9876;
214
+ }
215
+
216
+ /**
217
+ * Get a valid access token, refreshing or re-authenticating as needed
218
+ */
219
+ async getAccessToken(): Promise<string> {
220
+ let tokens = await this.storage.load(this.config.remoteUrl);
221
+
222
+ if (tokens) {
223
+ if (this.isTokenValid(tokens)) {
224
+ this.logger.debug('Using existing valid token');
225
+ return tokens.accessToken;
226
+ }
227
+
228
+ if (tokens.refreshToken) {
229
+ try {
230
+ tokens = await this.refreshToken(tokens.refreshToken);
231
+ this.logger.info('Token refreshed successfully');
232
+ return tokens.accessToken;
233
+ } catch (error) {
234
+ this.logger.warn('Token refresh failed, re-authenticating', error);
235
+ }
236
+ }
237
+ }
238
+
239
+ tokens = await this.performAuthorization();
240
+ return tokens.accessToken;
241
+ }
242
+
243
+ /**
244
+ * Clear stored tokens
245
+ */
246
+ async clearTokens(): Promise<void> {
247
+ await this.storage.delete(this.config.remoteUrl);
248
+ this.logger.info('Tokens cleared');
249
+ }
250
+
251
+ private isTokenValid(tokens: OAuthTokens): boolean {
252
+ if (!tokens.expiresAt) return true;
253
+ return tokens.expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS;
254
+ }
255
+
256
+ private async ensureClientRegistration(): Promise<ClientRegistration> {
257
+ if (this.clientRegistration) {
258
+ return this.clientRegistration;
259
+ }
260
+
261
+ this.logger.info('Discovering OAuth metadata...');
262
+ const metadata = await this.discoverMetadata(this.config.remoteUrl);
263
+
264
+ if (!metadata) {
265
+ throw new OAuthError(
266
+ 'Server does not support OAuth 2.1 (no metadata found)',
267
+ 'NO_OAUTH_METADATA'
268
+ );
269
+ }
270
+
271
+ this.logger.debug('OAuth metadata discovered', {
272
+ issuer: metadata.issuer,
273
+ hasRegistration: !!metadata.registrationEndpoint
274
+ });
275
+
276
+ this.logger.info('Registering OAuth client...');
277
+ const client = await this.registerClient(metadata, this.config.callbackUrl);
278
+ this.logger.debug('Client registered', { clientId: client.clientId });
279
+
280
+ this.clientRegistration = { metadata, client };
281
+ return this.clientRegistration;
282
+ }
283
+
284
+ // ==================== OAuth Protocol Methods ====================
285
+
286
+ private async discoverMetadata(serverUrl: string): Promise<OAuthServerMetadata | null> {
287
+ const baseUrl = new URL(serverUrl);
288
+ const wellKnownPaths = [
289
+ '/.well-known/oauth-authorization-server',
290
+ '/.well-known/openid-configuration',
291
+ ];
292
+
293
+ for (const path of wellKnownPaths) {
294
+ try {
295
+ const metadataUrl = new URL(path, baseUrl);
296
+ const response = await fetch(metadataUrl.toString(), {
297
+ method: 'GET',
298
+ headers: { 'Accept': 'application/json' },
299
+ signal: AbortSignal.timeout(this.config.timeout),
300
+ });
301
+
302
+ if (response.ok) {
303
+ const data = await response.json() as Record<string, unknown>;
304
+ return this.parseMetadata(data, baseUrl);
305
+ }
306
+ } catch {
307
+ // Continue to next path
308
+ }
309
+ }
310
+
311
+ return null;
312
+ }
313
+
314
+ private parseMetadata(data: Record<string, unknown>, baseUrl: URL): OAuthServerMetadata {
315
+ const getString = (key: string): string | undefined => {
316
+ const value = data[key];
317
+ return typeof value === 'string' ? value : undefined;
318
+ };
319
+
320
+ const getStringArray = (key: string): string[] | undefined => {
321
+ const value = data[key];
322
+ return Array.isArray(value) && value.every(v => typeof v === 'string') ? value : undefined;
323
+ };
324
+
325
+ const authorizationEndpoint = getString('authorization_endpoint');
326
+ const tokenEndpoint = getString('token_endpoint');
327
+
328
+ if (!authorizationEndpoint || !tokenEndpoint) {
329
+ throw new OAuthError(
330
+ 'Invalid metadata: missing required endpoints',
331
+ 'INVALID_METADATA'
332
+ );
333
+ }
334
+
335
+ return {
336
+ issuer: getString('issuer') ?? baseUrl.origin,
337
+ authorizationEndpoint,
338
+ tokenEndpoint,
339
+ registrationEndpoint: getString('registration_endpoint'),
340
+ jwksUri: getString('jwks_uri'),
341
+ scopesSupported: getStringArray('scopes_supported'),
342
+ responseTypesSupported: getStringArray('response_types_supported') ?? ['code'],
343
+ codeChallengeMethodsSupported: getStringArray('code_challenge_methods_supported'),
344
+ };
345
+ }
346
+
347
+ private async registerClient(
348
+ metadata: OAuthServerMetadata,
349
+ redirectUri: string
350
+ ): Promise<OAuthClientInfo> {
351
+ if (!metadata.registrationEndpoint) {
352
+ throw new OAuthError(
353
+ 'Server does not support dynamic client registration',
354
+ 'REGISTRATION_NOT_SUPPORTED'
355
+ );
356
+ }
357
+
358
+ const registrationRequest = {
359
+ client_name: 'MCP OAuth Proxy',
360
+ redirect_uris: [redirectUri],
361
+ grant_types: ['authorization_code', 'refresh_token'],
362
+ response_types: ['code'],
363
+ token_endpoint_auth_method: 'none',
364
+ };
365
+
366
+ const response = await fetch(metadata.registrationEndpoint, {
367
+ method: 'POST',
368
+ headers: {
369
+ 'Content-Type': 'application/json',
370
+ 'Accept': 'application/json',
371
+ },
372
+ body: JSON.stringify(registrationRequest),
373
+ signal: AbortSignal.timeout(this.config.timeout),
374
+ });
375
+
376
+ if (!response.ok) {
377
+ const errorText = await response.text();
378
+ throw new OAuthError(
379
+ `Client registration failed: ${errorText}`,
380
+ 'REGISTRATION_FAILED',
381
+ { status: response.status }
382
+ );
383
+ }
384
+
385
+ const data = await response.json() as RegistrationResponse;
386
+
387
+ return {
388
+ clientId: data.client_id,
389
+ clientSecret: data.client_secret,
390
+ redirectUri,
391
+ };
392
+ }
393
+
394
+ private async performAuthorization(): Promise<OAuthTokens> {
395
+ const { metadata, client } = await this.ensureClientRegistration();
396
+
397
+ await this.callbackServer.start(this.callbackPort);
398
+ this.logger.debug('Callback server started on port', this.callbackPort);
399
+
400
+ try {
401
+ // Generate PKCE parameters
402
+ const state = generateState();
403
+ const codeVerifier = generateCodeVerifier();
404
+ const codeChallenge = generateCodeChallenge(codeVerifier);
405
+
406
+ // Build authorization URL
407
+ const authUrl = new URL(metadata.authorizationEndpoint);
408
+ authUrl.searchParams.set('response_type', 'code');
409
+ authUrl.searchParams.set('client_id', client.clientId);
410
+ authUrl.searchParams.set('redirect_uri', client.redirectUri);
411
+ authUrl.searchParams.set('state', state);
412
+ authUrl.searchParams.set('code_challenge', codeChallenge);
413
+ authUrl.searchParams.set('code_challenge_method', 'S256');
414
+
415
+ if (metadata.scopesSupported && metadata.scopesSupported.length > 0) {
416
+ authUrl.searchParams.set('scope', metadata.scopesSupported.join(' '));
417
+ }
418
+
419
+ this.logger.info('Authorization required. Opening browser...');
420
+ this.logger.info(`Authorization URL: ${authUrl.toString()}`);
421
+
422
+ if (this.config.openBrowser) {
423
+ await openBrowser(authUrl.toString());
424
+ }
425
+
426
+ // Wait for callback
427
+ this.logger.info('Waiting for authorization...');
428
+ const result = await this.callbackServer.waitForCallback(state, AUTH_TIMEOUT_MS);
429
+
430
+ if ('error' in result) {
431
+ throw new OAuthError(
432
+ `Authorization failed: ${result.error}`,
433
+ 'AUTHORIZATION_FAILED'
434
+ );
435
+ }
436
+
437
+ // Exchange code for tokens
438
+ this.logger.debug('Exchanging code for tokens...');
439
+ const tokens = await this.exchangeCode(metadata, client, result.code, codeVerifier);
440
+
441
+ await this.storage.save(this.config.remoteUrl, tokens);
442
+ this.logger.info('Authorization complete, tokens saved');
443
+
444
+ return tokens;
445
+ } finally {
446
+ await this.callbackServer.stop();
447
+ this.logger.debug('Callback server stopped');
448
+ }
449
+ }
450
+
451
+ private async exchangeCode(
452
+ metadata: OAuthServerMetadata,
453
+ client: OAuthClientInfo,
454
+ code: string,
455
+ codeVerifier: string
456
+ ): Promise<OAuthTokens> {
457
+ const body = new URLSearchParams({
458
+ grant_type: 'authorization_code',
459
+ code,
460
+ redirect_uri: client.redirectUri,
461
+ client_id: client.clientId,
462
+ code_verifier: codeVerifier,
463
+ });
464
+
465
+ if (client.clientSecret) {
466
+ body.set('client_secret', client.clientSecret);
467
+ }
468
+
469
+ const response = await fetch(metadata.tokenEndpoint, {
470
+ method: 'POST',
471
+ headers: {
472
+ 'Content-Type': 'application/x-www-form-urlencoded',
473
+ 'Accept': 'application/json',
474
+ },
475
+ body: body.toString(),
476
+ signal: AbortSignal.timeout(this.config.timeout),
477
+ });
478
+
479
+ if (!response.ok) {
480
+ const errorText = await response.text();
481
+ throw new OAuthError(
482
+ `Token exchange failed: ${errorText}`,
483
+ 'TOKEN_EXCHANGE_FAILED',
484
+ { status: response.status }
485
+ );
486
+ }
487
+
488
+ const data = await response.json() as TokenResponse;
489
+ return this.parseTokenResponse(data);
490
+ }
491
+
492
+ private async refreshToken(refreshToken: string): Promise<OAuthTokens> {
493
+ const { metadata, client } = await this.ensureClientRegistration();
494
+
495
+ const body = new URLSearchParams({
496
+ grant_type: 'refresh_token',
497
+ refresh_token: refreshToken,
498
+ client_id: client.clientId,
499
+ });
500
+
501
+ if (client.clientSecret) {
502
+ body.set('client_secret', client.clientSecret);
503
+ }
504
+
505
+ const response = await fetch(metadata.tokenEndpoint, {
506
+ method: 'POST',
507
+ headers: {
508
+ 'Content-Type': 'application/x-www-form-urlencoded',
509
+ 'Accept': 'application/json',
510
+ },
511
+ body: body.toString(),
512
+ signal: AbortSignal.timeout(this.config.timeout),
513
+ });
514
+
515
+ if (!response.ok) {
516
+ const errorText = await response.text();
517
+ throw new OAuthError(
518
+ `Token refresh failed: ${errorText}`,
519
+ 'TOKEN_REFRESH_FAILED',
520
+ { status: response.status }
521
+ );
522
+ }
523
+
524
+ const data = await response.json() as TokenResponse;
525
+ const tokens = this.parseTokenResponse(data);
526
+ await this.storage.save(this.config.remoteUrl, tokens);
527
+
528
+ return tokens;
529
+ }
530
+
531
+ private parseTokenResponse(data: TokenResponse): OAuthTokens {
532
+ return {
533
+ accessToken: data.access_token,
534
+ tokenType: data.token_type,
535
+ refreshToken: data.refresh_token,
536
+ expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
537
+ scope: data.scope,
538
+ };
539
+ }
540
+ }