@netpad/mcp-server-remote 1.2.0 → 1.5.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/api/mcp.ts CHANGED
@@ -2,12 +2,20 @@ import type { VercelRequest, VercelResponse } from '@vercel/node';
2
2
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
3
  import { IncomingMessage, ServerResponse } from 'node:http';
4
4
  import { createNetPadMcpServer } from '@netpad/mcp-server';
5
+ import { validateAccessToken } from './lib/oauth.js';
6
+ import {
7
+ recordMetric,
8
+ startMetricsContext,
9
+ getDuration,
10
+ extractToolFromBody,
11
+ flushMetrics,
12
+ } from './lib/metrics.js';
5
13
 
6
14
  // Store transports by session ID for reconnection
7
15
  const transports = new Map<string, StreamableHTTPServerTransport>();
8
16
 
9
17
  // ============================================================================
10
- // API KEY AUTHENTICATION
18
+ // AUTHENTICATION (Supports both OAuth tokens and API keys)
11
19
  // ============================================================================
12
20
 
13
21
  // Cache validated API keys for 5 minutes to reduce API calls
@@ -16,28 +24,46 @@ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
16
24
 
17
25
  /**
18
26
  * Get the NetPad API base URL from environment or default
27
+ * Note: Using www.netpad.io because netpad.io redirects to www with 307
19
28
  */
20
29
  function getNetPadApiUrl(): string {
21
- return process.env.NETPAD_API_URL || 'https://netpad.io';
30
+ return process.env.NETPAD_API_URL || 'https://www.netpad.io';
22
31
  }
23
32
 
24
33
  /**
25
- * Validate the API key by calling the NetPad API.
26
- * Uses the existing NetPad API key validation system.
27
- *
28
- * API keys should be in the format: np_live_xxx or np_test_xxx
34
+ * Authentication result
35
+ */
36
+ interface AuthResult {
37
+ valid: boolean;
38
+ userId?: string;
39
+ organizationId?: string;
40
+ scope?: string;
41
+ error?: {
42
+ status: number;
43
+ error: string;
44
+ code: string;
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Validate the request using either OAuth token or API key
29
50
  *
30
- * @returns null if valid, or an error response object if invalid
51
+ * Supports:
52
+ * 1. OAuth access tokens (JWT-like tokens from /token endpoint)
53
+ * 2. NetPad API keys (np_live_xxx or np_test_xxx)
31
54
  */
32
- async function validateApiKey(req: VercelRequest): Promise<{ status: number; error: string; code: string } | null> {
55
+ async function validateRequest(req: VercelRequest): Promise<AuthResult> {
33
56
  const authHeader = req.headers['authorization'];
34
57
 
35
58
  // Check if Authorization header is present
36
59
  if (!authHeader) {
37
60
  return {
38
- status: 401,
39
- error: 'Missing Authorization header. Use: Authorization: Bearer np_live_xxx',
40
- code: 'MISSING_API_KEY',
61
+ valid: false,
62
+ error: {
63
+ status: 401,
64
+ error: 'Missing Authorization header. Use: Authorization: Bearer <token>',
65
+ code: 'MISSING_AUTH',
66
+ },
41
67
  };
42
68
  }
43
69
 
@@ -45,41 +71,94 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
45
71
  const parts = authHeader.split(' ');
46
72
  if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
47
73
  return {
48
- status: 401,
49
- error: 'Invalid Authorization header format. Use: Authorization: Bearer np_live_xxx',
50
- code: 'INVALID_AUTH_FORMAT',
74
+ valid: false,
75
+ error: {
76
+ status: 401,
77
+ error: 'Invalid Authorization header format. Use: Authorization: Bearer <token>',
78
+ code: 'INVALID_AUTH_FORMAT',
79
+ },
51
80
  };
52
81
  }
53
82
 
54
- const apiKey = parts[1].trim();
83
+ const token = parts[1].trim();
55
84
 
56
- if (!apiKey) {
85
+ if (!token) {
57
86
  return {
58
- status: 401,
59
- error: 'API key is empty',
60
- code: 'EMPTY_API_KEY',
87
+ valid: false,
88
+ error: {
89
+ status: 401,
90
+ error: 'Token is empty',
91
+ code: 'EMPTY_TOKEN',
92
+ },
61
93
  };
62
94
  }
63
95
 
64
- // Validate key format (must start with np_live_ or np_test_)
65
- if (!apiKey.startsWith('np_live_') && !apiKey.startsWith('np_test_')) {
96
+ // ============================================================================
97
+ // Try OAuth token first (JWT-like format with dots)
98
+ // ============================================================================
99
+ if (token.includes('.')) {
100
+ console.log('[MCP] Attempting OAuth token validation...');
101
+ const payload = validateAccessToken(token);
102
+ if (payload) {
103
+ console.log('[MCP] OAuth token valid:', { userId: payload.sub, org: payload.org });
104
+ return {
105
+ valid: true,
106
+ userId: payload.sub,
107
+ organizationId: payload.org,
108
+ scope: payload.scope,
109
+ };
110
+ }
111
+ // If it looks like a JWT but failed validation, return error
112
+ // (don't fall through to API key validation)
113
+ console.log('[MCP] OAuth token validation failed');
66
114
  return {
67
- status: 401,
68
- error: 'Invalid API key format. Keys should start with np_live_ or np_test_. Generate one at netpad.io/settings',
69
- code: 'INVALID_API_KEY_FORMAT',
115
+ valid: false,
116
+ error: {
117
+ status: 401,
118
+ error: 'Invalid or expired OAuth token',
119
+ code: 'INVALID_OAUTH_TOKEN',
120
+ },
70
121
  };
71
122
  }
72
123
 
124
+ // ============================================================================
125
+ // Try API key (np_live_xxx or np_test_xxx format)
126
+ // ============================================================================
127
+ if (token.startsWith('np_live_') || token.startsWith('np_test_')) {
128
+ return validateApiKey(token);
129
+ }
130
+
131
+ // Unknown token format
132
+ return {
133
+ valid: false,
134
+ error: {
135
+ status: 401,
136
+ error: 'Invalid token format. Use an OAuth token or NetPad API key (np_live_xxx)',
137
+ code: 'INVALID_TOKEN_FORMAT',
138
+ },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Validate a NetPad API key
144
+ */
145
+ async function validateApiKey(apiKey: string): Promise<AuthResult> {
73
146
  // Check cache first
74
147
  const cached = apiKeyCache.get(apiKey);
75
148
  if (cached && cached.expiresAt > Date.now()) {
76
149
  if (cached.valid) {
77
- return null; // Valid key from cache
150
+ return {
151
+ valid: true,
152
+ organizationId: cached.organizationId,
153
+ };
78
154
  }
79
155
  return {
80
- status: 401,
81
- error: 'Invalid or expired API key',
82
- code: 'INVALID_API_KEY',
156
+ valid: false,
157
+ error: {
158
+ status: 401,
159
+ error: 'Invalid or expired API key',
160
+ code: 'INVALID_API_KEY',
161
+ },
83
162
  };
84
163
  }
85
164
 
@@ -96,14 +175,18 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
96
175
  });
97
176
 
98
177
  if (response.ok) {
99
- const data = await response.json();
178
+ const data = await response.json() as { userId?: string; organizationId?: string };
100
179
  // Cache the valid result
101
180
  apiKeyCache.set(apiKey, {
102
181
  valid: true,
103
182
  organizationId: data.organizationId,
104
183
  expiresAt: Date.now() + CACHE_TTL_MS,
105
184
  });
106
- return null; // Valid key
185
+ return {
186
+ valid: true,
187
+ userId: data.userId,
188
+ organizationId: data.organizationId,
189
+ };
107
190
  }
108
191
 
109
192
  // Key is invalid - cache the negative result too (shorter TTL)
@@ -113,11 +196,14 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
113
196
  });
114
197
 
115
198
  // Parse error response
116
- const errorData = await response.json().catch(() => ({}));
199
+ const errorData = await response.json().catch(() => ({})) as { error?: { message?: string; code?: string } };
117
200
  return {
118
- status: response.status,
119
- error: errorData.error?.message || 'Invalid or expired API key',
120
- code: errorData.error?.code || 'INVALID_API_KEY',
201
+ valid: false,
202
+ error: {
203
+ status: response.status,
204
+ error: errorData.error?.message || 'Invalid or expired API key',
205
+ code: errorData.error?.code || 'INVALID_API_KEY',
206
+ },
121
207
  };
122
208
  } catch (error) {
123
209
  console.error('Error validating API key against NetPad:', error);
@@ -126,12 +212,14 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
126
212
  // This allows the MCP server to work even if NetPad API is temporarily unavailable
127
213
  // but only accepts properly formatted keys
128
214
  console.warn('NetPad API unavailable, accepting key based on format validation only');
129
- return null;
215
+ return { valid: true };
130
216
  }
131
217
  }
132
218
 
133
219
  // Main handler
134
220
  export default async function handler(req: VercelRequest, res: VercelResponse) {
221
+ const metricsContext = startMetricsContext();
222
+
135
223
  // Handle CORS preflight
136
224
  if (req.method === 'OPTIONS') {
137
225
  res.status(200).end();
@@ -141,16 +229,41 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
141
229
  // ============================================================================
142
230
  // AUTHENTICATE REQUEST
143
231
  // ============================================================================
144
- const authError = await validateApiKey(req);
145
- if (authError) {
146
- res.status(authError.status).json({
147
- error: authError.error,
148
- code: authError.code,
149
- hint: 'Generate an API key at netpad.io/settings and add it to Claude\'s connector settings under "Advanced settings".',
232
+ const authResult = await validateRequest(req);
233
+ if (!authResult.valid) {
234
+ // Record failed auth metric
235
+ recordMetric({
236
+ method: req.method as 'GET' | 'POST',
237
+ authMethod: 'none',
238
+ authSuccess: false,
239
+ durationMs: getDuration(metricsContext),
240
+ statusCode: authResult.error?.status || 401,
241
+ errorCode: authResult.error?.code,
242
+ });
243
+
244
+ // Flush metrics (serverless: memory is ephemeral)
245
+ flushMetrics().catch(() => {});
246
+
247
+ // Per MCP spec and RFC 9728, include WWW-Authenticate header with resource_metadata
248
+ // This tells OAuth clients where to find the authorization server
249
+ const resourceMetadataUrl = 'https://mcp.netpad.io/.well-known/oauth-protected-resource';
250
+ res.setHeader(
251
+ 'WWW-Authenticate',
252
+ `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp read write"`
253
+ );
254
+ res.status(authResult.error?.status || 401).json({
255
+ error: authResult.error?.error || 'Authentication failed',
256
+ code: authResult.error?.code || 'AUTH_FAILED',
257
+ hint: 'Connect via Claude.ai Settings > Connectors, or use an API key from netpad.io/settings',
150
258
  });
151
259
  return;
152
260
  }
153
261
 
262
+ // Determine auth method for metrics
263
+ const authHeader = req.headers['authorization'] || '';
264
+ const token = authHeader.split(' ')[1] || '';
265
+ const authMethod: 'oauth' | 'api_key' = token.includes('.') ? 'oauth' : 'api_key';
266
+
154
267
  // Get session ID from headers
155
268
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
156
269
 
@@ -164,7 +277,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
164
277
  // Create the full NetPad MCP server with all 80+ tools
165
278
  const server = createNetPadMcpServer({
166
279
  name: '@netpad/mcp-server-remote',
167
- version: '1.1.0',
280
+ version: '1.2.0',
168
281
  });
169
282
  await server.connect(transport);
170
283
 
@@ -173,6 +286,21 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
173
286
  transports.set(transport.sessionId, transport);
174
287
  }
175
288
 
289
+ // Record successful SSE connection metric
290
+ recordMetric({
291
+ method: 'GET',
292
+ organizationId: authResult.organizationId,
293
+ userId: authResult.userId,
294
+ authMethod,
295
+ authSuccess: true,
296
+ durationMs: getDuration(metricsContext),
297
+ statusCode: 200,
298
+ sessionId: transport.sessionId,
299
+ });
300
+
301
+ // Flush metrics (serverless: memory is ephemeral)
302
+ flushMetrics().catch(() => {});
303
+
176
304
  await transport.handleRequest(
177
305
  req as unknown as IncomingMessage,
178
306
  res as unknown as ServerResponse
@@ -182,6 +310,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
182
310
 
183
311
  // Handle POST requests
184
312
  if (req.method === 'POST') {
313
+ // Extract tool name from request body for metrics
314
+ const tool = extractToolFromBody(req.body);
315
+
185
316
  // Try to reuse existing transport for this session
186
317
  let transport = sessionId ? transports.get(sessionId) : undefined;
187
318
 
@@ -194,7 +325,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
194
325
  // Create the full NetPad MCP server with all 80+ tools
195
326
  const server = createNetPadMcpServer({
196
327
  name: '@netpad/mcp-server-remote',
197
- version: '1.1.0',
328
+ version: '1.2.0',
198
329
  });
199
330
  await server.connect(transport);
200
331
 
@@ -208,9 +339,34 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
208
339
  res as unknown as ServerResponse,
209
340
  req.body
210
341
  );
342
+
343
+ // Record POST request metric
344
+ recordMetric({
345
+ method: 'POST',
346
+ organizationId: authResult.organizationId,
347
+ userId: authResult.userId,
348
+ tool,
349
+ authMethod,
350
+ authSuccess: true,
351
+ durationMs: getDuration(metricsContext),
352
+ statusCode: 200,
353
+ sessionId: sessionId || transport.sessionId,
354
+ });
355
+
356
+ // Opportunistically flush metrics (non-blocking)
357
+ flushMetrics().catch(() => {});
211
358
  return;
212
359
  }
213
360
 
214
361
  // Method not allowed
362
+ recordMetric({
363
+ method: req.method as 'GET' | 'POST',
364
+ authMethod,
365
+ authSuccess: true,
366
+ durationMs: getDuration(metricsContext),
367
+ statusCode: 405,
368
+ errorCode: 'METHOD_NOT_ALLOWED',
369
+ });
370
+ flushMetrics().catch(() => {});
215
371
  res.status(405).json({ error: 'Method not allowed' });
216
372
  }
@@ -0,0 +1,75 @@
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
+ // MCP REQUIRED: Support for Client ID Metadata Documents
54
+ // This allows Claude.ai to use its hosted client metadata
55
+ client_id_metadata_document_supported: true,
56
+
57
+ // OPTIONAL: Service documentation
58
+ service_documentation: 'https://docs.netpad.io/docs/developer/mcp-server',
59
+
60
+ // OPTIONAL: MCP-specific metadata
61
+ mcp_endpoint: `${issuer}/mcp`,
62
+
63
+ // Custom: NetPad-specific info
64
+ netpad: {
65
+ name: 'NetPad MCP Server',
66
+ version: '1.2.0',
67
+ tools_count: '80+',
68
+ api_key_url: 'https://netpad.io/settings',
69
+ },
70
+ };
71
+
72
+ res.setHeader('Content-Type', 'application/json');
73
+ res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
74
+ res.status(200).json(metadata);
75
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OAuth 2.0 Protected Resource Metadata
3
+ *
4
+ * GET /.well-known/oauth-protected-resource
5
+ *
6
+ * This endpoint tells Claude.ai where to find the OAuth authorization server.
7
+ * Claude.ai queries this endpoint FIRST before initiating the OAuth flow.
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 Protected Resource Metadata (RFC 9728)
22
+ // This tells clients where to find the authorization server
23
+ const metadata = {
24
+ // The resource server (this MCP server)
25
+ resource: issuer,
26
+
27
+ // Where to find the authorization server(s)
28
+ authorization_servers: [issuer],
29
+
30
+ // Bearer token is the only supported method
31
+ bearer_methods_supported: ['header'],
32
+
33
+ // Scopes required to access this resource
34
+ scopes_supported: Object.keys(OAUTH_CONFIG.scopes),
35
+ };
36
+
37
+ res.setHeader('Content-Type', 'application/json');
38
+ res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
39
+ res.status(200).json(metadata);
40
+ }