@netpad/mcp-server-remote 1.2.0 → 1.4.2

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,71 @@
1
+ /**
2
+ * OAuth 2.0 Authorization Server Metadata
3
+ *
4
+ * GET /.well-known/oauth-authorization-server
5
+ *
6
+ * This endpoint returns metadata about the OAuth server according to RFC 8414.
7
+ * Claude.ai and other OAuth clients can use this to discover endpoints.
8
+ */
9
+
10
+ import type { VercelRequest, VercelResponse } from '@vercel/node';
11
+ import { OAUTH_CONFIG } from '../lib/oauth.js';
12
+
13
+ export default function handler(req: VercelRequest, res: VercelResponse) {
14
+ if (req.method !== 'GET') {
15
+ res.status(405).json({ error: 'Method not allowed' });
16
+ return;
17
+ }
18
+
19
+ const issuer = OAUTH_CONFIG.issuer;
20
+
21
+ // OAuth 2.0 Authorization Server Metadata (RFC 8414)
22
+ const metadata = {
23
+ // REQUIRED: Issuer identifier
24
+ issuer: issuer,
25
+
26
+ // REQUIRED: Authorization endpoint
27
+ authorization_endpoint: `${issuer}/authorize`,
28
+
29
+ // REQUIRED: Token endpoint
30
+ token_endpoint: `${issuer}/token`,
31
+
32
+ // OPTIONAL: Registration endpoint (not implemented)
33
+ // registration_endpoint: `${issuer}/register`,
34
+
35
+ // OPTIONAL: Scopes supported
36
+ scopes_supported: Object.keys(OAUTH_CONFIG.scopes),
37
+
38
+ // REQUIRED: Response types supported
39
+ response_types_supported: ['code'],
40
+
41
+ // OPTIONAL: Response modes supported
42
+ response_modes_supported: ['query'],
43
+
44
+ // OPTIONAL: Grant types supported
45
+ grant_types_supported: ['authorization_code', 'refresh_token'],
46
+
47
+ // OPTIONAL: Token endpoint authentication methods
48
+ token_endpoint_auth_methods_supported: ['none'],
49
+
50
+ // OPTIONAL: PKCE code challenge methods (RFC 7636)
51
+ code_challenge_methods_supported: ['S256', 'plain'],
52
+
53
+ // OPTIONAL: Service documentation
54
+ service_documentation: 'https://docs.netpad.io/docs/developer/mcp-server',
55
+
56
+ // OPTIONAL: MCP-specific metadata
57
+ mcp_endpoint: `${issuer}/mcp`,
58
+
59
+ // Custom: NetPad-specific info
60
+ netpad: {
61
+ name: 'NetPad MCP Server',
62
+ version: '1.2.0',
63
+ tools_count: '80+',
64
+ api_key_url: 'https://netpad.io/settings',
65
+ },
66
+ };
67
+
68
+ res.setHeader('Content-Type', 'application/json');
69
+ res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
70
+ res.status(200).json(metadata);
71
+ }
@@ -0,0 +1,538 @@
1
+ /**
2
+ * OAuth 2.0 Authorization Endpoint
3
+ *
4
+ * GET /authorize - Initiates the OAuth flow
5
+ *
6
+ * This endpoint:
7
+ * 1. Validates the OAuth client and redirect URI
8
+ * 2. Redirects to NetPad login if user is not authenticated
9
+ * 3. Shows consent screen (or auto-approves for trusted clients)
10
+ * 4. Redirects back to Claude.ai with an authorization code
11
+ */
12
+
13
+ import type { VercelRequest, VercelResponse } from '@vercel/node';
14
+ import {
15
+ OAUTH_CONFIG,
16
+ validateClient,
17
+ validateScopes,
18
+ generateAuthorizationCode,
19
+ } from './lib/oauth.js';
20
+
21
+ /**
22
+ * Generate the HTML for the authorization page
23
+ */
24
+ function generateAuthorizationPage(params: {
25
+ clientId: string;
26
+ redirectUri: string;
27
+ scope: string;
28
+ state: string;
29
+ codeChallenge: string;
30
+ codeChallengeMethod: string;
31
+ error?: string;
32
+ }): string {
33
+ const netpadUrl = OAUTH_CONFIG.netpadApiUrl;
34
+ const scopes = params.scope.split(' ').filter(Boolean);
35
+
36
+ return `<!DOCTYPE html>
37
+ <html lang="en">
38
+ <head>
39
+ <meta charset="UTF-8">
40
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
41
+ <title>Authorize NetPad - Claude.ai</title>
42
+ <style>
43
+ * {
44
+ box-sizing: border-box;
45
+ margin: 0;
46
+ padding: 0;
47
+ }
48
+ body {
49
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
50
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
51
+ min-height: 100vh;
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ padding: 20px;
56
+ }
57
+ .container {
58
+ background: white;
59
+ border-radius: 16px;
60
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
61
+ max-width: 420px;
62
+ width: 100%;
63
+ overflow: hidden;
64
+ }
65
+ .header {
66
+ background: linear-gradient(135deg, #00d9a5 0%, #00b894 100%);
67
+ padding: 32px;
68
+ text-align: center;
69
+ }
70
+ .logo {
71
+ width: 200px;
72
+ height: 200px;
73
+ margin: 0 auto 12px;
74
+ }
75
+ .logo img {
76
+ width: 100%;
77
+ height: 100%;
78
+ object-fit: contain;
79
+ }
80
+ .header h1 {
81
+ color: white;
82
+ font-size: 24px;
83
+ font-weight: 600;
84
+ }
85
+ .header p {
86
+ color: rgba(255, 255, 255, 0.9);
87
+ margin-top: 8px;
88
+ font-size: 14px;
89
+ }
90
+ .content {
91
+ padding: 32px;
92
+ }
93
+ .app-info {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 16px;
97
+ padding: 16px;
98
+ background: #f8f9fa;
99
+ border-radius: 12px;
100
+ margin-bottom: 24px;
101
+ }
102
+ .app-icon {
103
+ width: 48px;
104
+ height: 48px;
105
+ background: #5436da;
106
+ border-radius: 10px;
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: center;
110
+ color: white;
111
+ font-weight: bold;
112
+ font-size: 18px;
113
+ }
114
+ .app-details h3 {
115
+ font-size: 16px;
116
+ color: #333;
117
+ }
118
+ .app-details p {
119
+ font-size: 13px;
120
+ color: #666;
121
+ margin-top: 2px;
122
+ }
123
+ .permissions {
124
+ margin-bottom: 24px;
125
+ }
126
+ .permissions h4 {
127
+ font-size: 14px;
128
+ color: #333;
129
+ margin-bottom: 12px;
130
+ }
131
+ .permission-item {
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 12px;
135
+ padding: 12px;
136
+ background: #f8f9fa;
137
+ border-radius: 8px;
138
+ margin-bottom: 8px;
139
+ }
140
+ .permission-icon {
141
+ width: 32px;
142
+ height: 32px;
143
+ background: #e8f5e9;
144
+ border-radius: 50%;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ color: #4caf50;
149
+ }
150
+ .permission-text {
151
+ font-size: 14px;
152
+ color: #333;
153
+ }
154
+ .error-message {
155
+ background: #ffebee;
156
+ color: #c62828;
157
+ padding: 12px 16px;
158
+ border-radius: 8px;
159
+ margin-bottom: 16px;
160
+ font-size: 14px;
161
+ }
162
+ .form-group {
163
+ margin-bottom: 16px;
164
+ }
165
+ .form-group label {
166
+ display: block;
167
+ font-size: 14px;
168
+ color: #333;
169
+ margin-bottom: 8px;
170
+ font-weight: 500;
171
+ }
172
+ .form-group input {
173
+ width: 100%;
174
+ padding: 12px 16px;
175
+ border: 2px solid #e0e0e0;
176
+ border-radius: 8px;
177
+ font-size: 16px;
178
+ transition: border-color 0.2s;
179
+ }
180
+ .form-group input:focus {
181
+ outline: none;
182
+ border-color: #00b894;
183
+ }
184
+ .buttons {
185
+ display: flex;
186
+ gap: 12px;
187
+ margin-top: 24px;
188
+ }
189
+ .btn {
190
+ flex: 1;
191
+ padding: 14px 24px;
192
+ border: none;
193
+ border-radius: 8px;
194
+ font-size: 16px;
195
+ font-weight: 600;
196
+ cursor: pointer;
197
+ transition: all 0.2s;
198
+ }
199
+ .btn-primary {
200
+ background: #00b894;
201
+ color: white;
202
+ }
203
+ .btn-primary:hover {
204
+ background: #00a383;
205
+ }
206
+ .btn-secondary {
207
+ background: #f5f5f5;
208
+ color: #666;
209
+ }
210
+ .btn-secondary:hover {
211
+ background: #e0e0e0;
212
+ }
213
+ .footer {
214
+ text-align: center;
215
+ padding: 16px 32px 32px;
216
+ font-size: 12px;
217
+ color: #999;
218
+ }
219
+ .footer a {
220
+ color: #00b894;
221
+ text-decoration: none;
222
+ }
223
+ </style>
224
+ </head>
225
+ <body>
226
+ <div class="container">
227
+ <div class="header">
228
+ <div class="logo">
229
+ <img src="${netpadUrl}/micro-mark-black-trans.png" alt="NetPad" onerror="this.parentElement.innerHTML='NP'">
230
+ </div>
231
+ <h1>Connect to NetPad</h1>
232
+ <p>Authorize Claude.ai to access your NetPad workspace</p>
233
+ </div>
234
+ <div class="content">
235
+ ${params.error ? `<div class="error-message">${params.error}</div>` : ''}
236
+
237
+ <div class="app-info">
238
+ <div class="app-icon">C</div>
239
+ <div class="app-details">
240
+ <h3>Claude.ai</h3>
241
+ <p>wants to access your NetPad account</p>
242
+ </div>
243
+ </div>
244
+
245
+ <div class="permissions">
246
+ <h4>This will allow Claude.ai to:</h4>
247
+ ${scopes.map(scope => {
248
+ const descriptions: Record<string, string> = {
249
+ 'claudeai': 'Connect via Claude.ai interface',
250
+ 'mcp': 'Use NetPad MCP tools (80+ tools)',
251
+ 'read': 'View your forms, workflows, and data',
252
+ 'write': 'Create and modify forms and workflows',
253
+ };
254
+ return `
255
+ <div class="permission-item">
256
+ <div class="permission-icon">&#10003;</div>
257
+ <span class="permission-text">${descriptions[scope] || scope}</span>
258
+ </div>
259
+ `;
260
+ }).join('')}
261
+ </div>
262
+
263
+ <form method="POST" action="/api/authorize">
264
+ <input type="hidden" name="client_id" value="${params.clientId}">
265
+ <input type="hidden" name="redirect_uri" value="${params.redirectUri}">
266
+ <input type="hidden" name="scope" value="${params.scope}">
267
+ <input type="hidden" name="state" value="${params.state}">
268
+ <input type="hidden" name="code_challenge" value="${params.codeChallenge}">
269
+ <input type="hidden" name="code_challenge_method" value="${params.codeChallengeMethod}">
270
+
271
+ <div class="form-group">
272
+ <label for="api_key">Your NetPad API Key</label>
273
+ <input
274
+ type="password"
275
+ id="api_key"
276
+ name="api_key"
277
+ placeholder="np_live_xxxxx or np_test_xxxxx"
278
+ required
279
+ autocomplete="off"
280
+ >
281
+ </div>
282
+
283
+ <p style="font-size: 13px; color: #666; margin-bottom: 16px;">
284
+ Don't have an API key? <a href="${netpadUrl}/settings" target="_blank" style="color: #00b894;">Create one in NetPad Settings</a>
285
+ </p>
286
+
287
+ <div class="buttons">
288
+ <button type="button" class="btn btn-secondary" onclick="handleDeny()">Deny</button>
289
+ <button type="submit" class="btn btn-primary">Authorize</button>
290
+ </div>
291
+ </form>
292
+ </div>
293
+ <div class="footer">
294
+ By authorizing, you agree to NetPad's <a href="${netpadUrl}/terms">Terms of Service</a>
295
+ and <a href="${netpadUrl}/privacy">Privacy Policy</a>.
296
+ </div>
297
+ </div>
298
+
299
+ <script>
300
+ function handleDeny() {
301
+ const redirectUri = new URL('${params.redirectUri}');
302
+ redirectUri.searchParams.set('error', 'access_denied');
303
+ redirectUri.searchParams.set('error_description', 'User denied the authorization request');
304
+ ${params.state ? `redirectUri.searchParams.set('state', '${params.state}');` : ''}
305
+ window.location.href = redirectUri.toString();
306
+ }
307
+ </script>
308
+ </body>
309
+ </html>`;
310
+ }
311
+
312
+ export default async function handler(req: VercelRequest, res: VercelResponse) {
313
+ // Handle both GET (show form) and POST (process authorization)
314
+
315
+ if (req.method === 'GET') {
316
+ // ========================================================================
317
+ // GET /authorize - Show authorization page
318
+ // ========================================================================
319
+
320
+ const {
321
+ response_type,
322
+ client_id,
323
+ redirect_uri,
324
+ code_challenge,
325
+ code_challenge_method,
326
+ state,
327
+ scope,
328
+ } = req.query;
329
+
330
+ // Validate required parameters
331
+ if (response_type !== 'code') {
332
+ res.status(400).json({
333
+ error: 'unsupported_response_type',
334
+ error_description: 'Only "code" response_type is supported',
335
+ });
336
+ return;
337
+ }
338
+
339
+ if (!client_id || typeof client_id !== 'string') {
340
+ res.status(400).json({
341
+ error: 'invalid_request',
342
+ error_description: 'client_id is required',
343
+ });
344
+ return;
345
+ }
346
+
347
+ if (!redirect_uri || typeof redirect_uri !== 'string') {
348
+ res.status(400).json({
349
+ error: 'invalid_request',
350
+ error_description: 'redirect_uri is required',
351
+ });
352
+ return;
353
+ }
354
+
355
+ // Validate client and redirect URI
356
+ const clientValidation = validateClient(client_id, redirect_uri);
357
+ if (!clientValidation.valid) {
358
+ res.status(400).json({
359
+ error: clientValidation.error,
360
+ error_description: 'Invalid client_id or redirect_uri',
361
+ });
362
+ return;
363
+ }
364
+
365
+ // PKCE is required
366
+ if (!code_challenge || typeof code_challenge !== 'string') {
367
+ res.status(400).json({
368
+ error: 'invalid_request',
369
+ error_description: 'code_challenge is required (PKCE)',
370
+ });
371
+ return;
372
+ }
373
+
374
+ const challengeMethod = (code_challenge_method as string) || 'S256';
375
+ if (challengeMethod !== 'S256' && challengeMethod !== 'plain') {
376
+ res.status(400).json({
377
+ error: 'invalid_request',
378
+ error_description: 'code_challenge_method must be S256 or plain',
379
+ });
380
+ return;
381
+ }
382
+
383
+ // Generate and serve the authorization page
384
+ const html = generateAuthorizationPage({
385
+ clientId: client_id,
386
+ redirectUri: redirect_uri,
387
+ scope: (scope as string) || 'mcp',
388
+ state: (state as string) || '',
389
+ codeChallenge: code_challenge,
390
+ codeChallengeMethod: challengeMethod,
391
+ });
392
+
393
+ res.setHeader('Content-Type', 'text/html');
394
+ res.status(200).send(html);
395
+ return;
396
+ }
397
+
398
+ if (req.method === 'POST') {
399
+ // ========================================================================
400
+ // POST /authorize - Process authorization (user submitted the form)
401
+ // ========================================================================
402
+
403
+ const {
404
+ client_id,
405
+ redirect_uri,
406
+ scope,
407
+ state,
408
+ code_challenge,
409
+ code_challenge_method,
410
+ api_key,
411
+ } = req.body;
412
+
413
+ // Validate the API key against NetPad
414
+ if (!api_key || typeof api_key !== 'string') {
415
+ const html = generateAuthorizationPage({
416
+ clientId: client_id,
417
+ redirectUri: redirect_uri,
418
+ scope: scope || 'mcp',
419
+ state: state || '',
420
+ codeChallenge: code_challenge,
421
+ codeChallengeMethod: code_challenge_method || 'S256',
422
+ error: 'Please enter your NetPad API key',
423
+ });
424
+ res.setHeader('Content-Type', 'text/html');
425
+ res.status(400).send(html);
426
+ return;
427
+ }
428
+
429
+ // Validate API key format
430
+ if (!api_key.startsWith('np_live_') && !api_key.startsWith('np_test_')) {
431
+ const html = generateAuthorizationPage({
432
+ clientId: client_id,
433
+ redirectUri: redirect_uri,
434
+ scope: scope || 'mcp',
435
+ state: state || '',
436
+ codeChallenge: code_challenge,
437
+ codeChallengeMethod: code_challenge_method || 'S256',
438
+ error: 'Invalid API key format. Keys should start with np_live_ or np_test_',
439
+ });
440
+ res.setHeader('Content-Type', 'text/html');
441
+ res.status(400).send(html);
442
+ return;
443
+ }
444
+
445
+ // Validate the API key against NetPad API
446
+ try {
447
+ const netpadUrl = OAUTH_CONFIG.netpadApiUrl;
448
+ console.log(`[OAuth] Validating API key against ${netpadUrl}/api/v1/auth/validate`);
449
+ console.log(`[OAuth] Key prefix: ${api_key.substring(0, 16)}...`);
450
+
451
+ const response = await fetch(`${netpadUrl}/api/v1/auth/validate`, {
452
+ method: 'POST',
453
+ headers: {
454
+ 'Content-Type': 'application/json',
455
+ 'Authorization': `Bearer ${api_key}`,
456
+ },
457
+ body: JSON.stringify({ source: 'mcp-server-remote-oauth' }),
458
+ });
459
+
460
+ console.log(`[OAuth] Validation response status: ${response.status}`);
461
+
462
+ if (!response.ok) {
463
+ const errorBody = await response.text();
464
+ console.log(`[OAuth] Validation error response: ${errorBody}`);
465
+
466
+ let errorMessage = 'Invalid or expired API key. Please check your key and try again.';
467
+ try {
468
+ const errorJson = JSON.parse(errorBody);
469
+ if (errorJson.error?.message) {
470
+ errorMessage = errorJson.error.message;
471
+ }
472
+ } catch {
473
+ // Keep default error message
474
+ }
475
+
476
+ const html = generateAuthorizationPage({
477
+ clientId: client_id,
478
+ redirectUri: redirect_uri,
479
+ scope: scope || 'mcp',
480
+ state: state || '',
481
+ codeChallenge: code_challenge,
482
+ codeChallengeMethod: code_challenge_method || 'S256',
483
+ error: errorMessage,
484
+ });
485
+ res.setHeader('Content-Type', 'text/html');
486
+ res.status(400).send(html);
487
+ return;
488
+ }
489
+
490
+ const userData = await response.json();
491
+ const userId = userData.userId || userData.user?.id || 'unknown';
492
+ const organizationId = userData.organizationId || userData.organization?.id || 'unknown';
493
+
494
+ // Validate scopes
495
+ const validatedScopes = validateScopes(scope || 'mcp', client_id);
496
+
497
+ // Generate authorization code
498
+ const authCode = generateAuthorizationCode({
499
+ clientId: client_id,
500
+ redirectUri: redirect_uri,
501
+ scope: validatedScopes.join(' '),
502
+ codeChallenge: code_challenge,
503
+ codeChallengeMethod: code_challenge_method || 'S256',
504
+ userId,
505
+ organizationId,
506
+ });
507
+
508
+ // Build redirect URL with authorization code
509
+ const redirectUrl = new URL(redirect_uri);
510
+ redirectUrl.searchParams.set('code', authCode);
511
+ if (state) {
512
+ redirectUrl.searchParams.set('state', state);
513
+ }
514
+
515
+ // Redirect back to Claude.ai
516
+ res.redirect(302, redirectUrl.toString());
517
+ return;
518
+ } catch (error) {
519
+ console.error('Error validating API key:', error);
520
+
521
+ const html = generateAuthorizationPage({
522
+ clientId: client_id,
523
+ redirectUri: redirect_uri,
524
+ scope: scope || 'mcp',
525
+ state: state || '',
526
+ codeChallenge: code_challenge,
527
+ codeChallengeMethod: code_challenge_method || 'S256',
528
+ error: 'Unable to validate API key. Please try again.',
529
+ });
530
+ res.setHeader('Content-Type', 'text/html');
531
+ res.status(500).send(html);
532
+ return;
533
+ }
534
+ }
535
+
536
+ // Method not allowed
537
+ res.status(405).json({ error: 'Method not allowed' });
538
+ }
package/api/index.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  import type { VercelRequest, VercelResponse } from '@vercel/node';
2
2
 
3
3
  export default function handler(req: VercelRequest, res: VercelResponse) {
4
+ const baseUrl = process.env.OAUTH_ISSUER || 'https://mcp.netpad.io';
5
+
4
6
  res.status(200).json({
5
7
  name: '@netpad/mcp-server-remote',
6
- version: '1.1.0',
8
+ version: '1.2.0',
7
9
  description: 'NetPad MCP Server - Remote API for Claude Custom Connectors. Includes all 80+ tools from @netpad/mcp-server.',
8
10
  endpoints: {
9
11
  mcp: '/mcp',
10
12
  health: '/health',
13
+ authorize: '/authorize',
14
+ token: '/token',
15
+ oauth_metadata: '/.well-known/oauth-authorization-server',
11
16
  },
12
17
  features: {
13
18
  tools: '80+ tools',
@@ -25,9 +30,27 @@ export default function handler(req: VercelRequest, res: VercelResponse) {
25
30
  ],
26
31
  },
27
32
  authentication: {
28
- type: 'Bearer token',
29
- format: 'Authorization: Bearer np_live_xxx',
30
- generateAt: 'https://netpad.io/settings',
33
+ methods: ['oauth2', 'api_key'],
34
+ oauth2: {
35
+ type: 'OAuth 2.0 + PKCE',
36
+ authorization_endpoint: `${baseUrl}/authorize`,
37
+ token_endpoint: `${baseUrl}/token`,
38
+ metadata_endpoint: `${baseUrl}/.well-known/oauth-authorization-server`,
39
+ },
40
+ api_key: {
41
+ type: 'Bearer token',
42
+ format: 'Authorization: Bearer np_live_xxx',
43
+ generateAt: 'https://netpad.io/settings',
44
+ },
45
+ },
46
+ claude_ai_setup: {
47
+ instructions: [
48
+ '1. Go to Claude.ai Settings > Connectors',
49
+ '2. Click "Add custom connector"',
50
+ `3. Enter URL: ${baseUrl}/mcp`,
51
+ '4. Click "Connect" to authorize with your NetPad account',
52
+ '5. Enable the connector in your conversations',
53
+ ],
31
54
  },
32
55
  documentation: 'https://docs.netpad.io/docs/developer/mcp-server',
33
56
  });