@netpad/mcp-server-remote 1.4.2 → 1.5.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.
package/api/authorize.ts CHANGED
@@ -487,7 +487,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
487
487
  return;
488
488
  }
489
489
 
490
- const userData = await response.json();
490
+ const userData = await response.json() as { userId?: string; user?: { id?: string }; organizationId?: string; organization?: { id?: string } };
491
491
  const userId = userData.userId || userData.user?.id || 'unknown';
492
492
  const organizationId = userData.organizationId || userData.organization?.id || 'unknown';
493
493
 
@@ -0,0 +1,208 @@
1
+ /**
2
+ * MCP Server Metrics Collector
3
+ *
4
+ * Collects usage metrics from the MCP server and batches them for reporting
5
+ * to the NetPad API. Designed for Vercel serverless environment.
6
+ */
7
+
8
+ // ============================================
9
+ // Types
10
+ // ============================================
11
+
12
+ export interface MCPRequestMetric {
13
+ timestamp: number;
14
+ organizationId?: string;
15
+ userId?: string;
16
+ method: 'GET' | 'POST';
17
+ tool?: string;
18
+ authMethod: 'oauth' | 'api_key' | 'none';
19
+ authSuccess: boolean;
20
+ durationMs: number;
21
+ statusCode: number;
22
+ errorCode?: string;
23
+ sessionId?: string;
24
+ }
25
+
26
+ export interface MCPMetricsBatch {
27
+ serverVersion: string;
28
+ environment: string;
29
+ collectedAt: number;
30
+ metrics: MCPRequestMetric[];
31
+ }
32
+
33
+ // ============================================
34
+ // Configuration
35
+ // ============================================
36
+
37
+ // In serverless environments, flush more aggressively since memory is ephemeral
38
+ const BATCH_SIZE = 10; // Reduced from 50 for serverless
39
+ const FLUSH_INTERVAL_MS = 10000; // 10 seconds (reduced from 30)
40
+
41
+ // In-memory buffer for metrics (serverless-friendly)
42
+ let metricsBuffer: MCPRequestMetric[] = [];
43
+ let lastFlushTime = Date.now();
44
+
45
+ /**
46
+ * Get the NetPad API base URL
47
+ */
48
+ function getNetPadApiUrl(): string {
49
+ return process.env.NETPAD_API_URL || 'https://www.netpad.io';
50
+ }
51
+
52
+ /**
53
+ * Get MCP server version from package or environment
54
+ */
55
+ function getServerVersion(): string {
56
+ return process.env.MCP_SERVER_VERSION || '1.4.2';
57
+ }
58
+
59
+ /**
60
+ * Get environment (production, staging, development)
61
+ */
62
+ function getEnvironment(): string {
63
+ return process.env.VERCEL_ENV || process.env.NODE_ENV || 'development';
64
+ }
65
+
66
+ // ============================================
67
+ // Metrics Collection
68
+ // ============================================
69
+
70
+ /**
71
+ * Record a single MCP request metric
72
+ */
73
+ export function recordMetric(metric: Omit<MCPRequestMetric, 'timestamp'>): void {
74
+ const fullMetric: MCPRequestMetric = {
75
+ ...metric,
76
+ timestamp: Date.now(),
77
+ };
78
+
79
+ metricsBuffer.push(fullMetric);
80
+ console.log('[Metrics] Recorded:', {
81
+ method: metric.method,
82
+ tool: metric.tool,
83
+ authMethod: metric.authMethod,
84
+ durationMs: metric.durationMs,
85
+ statusCode: metric.statusCode,
86
+ });
87
+
88
+ // Check if we should flush
89
+ if (metricsBuffer.length >= BATCH_SIZE) {
90
+ flushMetrics().catch((err) => {
91
+ console.error('[Metrics] Flush error:', err);
92
+ });
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Create a metrics context for timing a request
98
+ */
99
+ export function startMetricsContext(): { startTime: number } {
100
+ return { startTime: Date.now() };
101
+ }
102
+
103
+ /**
104
+ * Calculate duration from metrics context
105
+ */
106
+ export function getDuration(context: { startTime: number }): number {
107
+ return Date.now() - context.startTime;
108
+ }
109
+
110
+ // ============================================
111
+ // Metrics Flushing
112
+ // ============================================
113
+
114
+ /**
115
+ * Flush accumulated metrics to the NetPad API
116
+ */
117
+ export async function flushMetrics(): Promise<void> {
118
+ if (metricsBuffer.length === 0) {
119
+ return;
120
+ }
121
+
122
+ // Swap buffer to avoid race conditions
123
+ const metricsToFlush = metricsBuffer;
124
+ metricsBuffer = [];
125
+ lastFlushTime = Date.now();
126
+
127
+ const batch: MCPMetricsBatch = {
128
+ serverVersion: getServerVersion(),
129
+ environment: getEnvironment(),
130
+ collectedAt: Date.now(),
131
+ metrics: metricsToFlush,
132
+ };
133
+
134
+ try {
135
+ const netpadUrl = getNetPadApiUrl();
136
+ const response = await fetch(`${netpadUrl}/api/v1/mcp-metrics`, {
137
+ method: 'POST',
138
+ headers: {
139
+ 'Content-Type': 'application/json',
140
+ 'X-MCP-Server-Version': getServerVersion(),
141
+ },
142
+ body: JSON.stringify(batch),
143
+ });
144
+
145
+ if (!response.ok) {
146
+ // Put metrics back on failure (at the front)
147
+ metricsBuffer = [...metricsToFlush, ...metricsBuffer];
148
+ console.error('[Metrics] Failed to flush:', response.status, await response.text());
149
+ } else {
150
+ console.log('[Metrics] Flushed', metricsToFlush.length, 'metrics');
151
+ }
152
+ } catch (error) {
153
+ // Put metrics back on network error
154
+ metricsBuffer = [...metricsToFlush, ...metricsBuffer];
155
+ console.error('[Metrics] Network error during flush:', error);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Force flush all metrics (useful before process exit)
161
+ */
162
+ export async function forceFlush(): Promise<void> {
163
+ await flushMetrics();
164
+ }
165
+
166
+ // ============================================
167
+ // Helper Functions
168
+ // ============================================
169
+
170
+ /**
171
+ * Extract tool name from JSON-RPC request body
172
+ */
173
+ export function extractToolFromBody(body: unknown): string | undefined {
174
+ if (!body || typeof body !== 'object') {
175
+ return undefined;
176
+ }
177
+
178
+ const request = body as Record<string, unknown>;
179
+
180
+ // JSON-RPC format: { method: 'tools/call', params: { name: 'tool_name' } }
181
+ if (request.method === 'tools/call' && request.params && typeof request.params === 'object') {
182
+ const params = request.params as Record<string, unknown>;
183
+ if (typeof params.name === 'string') {
184
+ return params.name;
185
+ }
186
+ }
187
+
188
+ // Also check for direct method calls
189
+ if (typeof request.method === 'string' && request.method !== 'tools/call') {
190
+ return request.method;
191
+ }
192
+
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Get buffered metrics count (for testing/debugging)
198
+ */
199
+ export function getBufferedMetricsCount(): number {
200
+ return metricsBuffer.length;
201
+ }
202
+
203
+ /**
204
+ * Get time since last flush (for testing/debugging)
205
+ */
206
+ export function getTimeSinceLastFlush(): number {
207
+ return Date.now() - lastFlushTime;
208
+ }
package/api/lib/oauth.ts CHANGED
@@ -376,36 +376,100 @@ export function exchangeRefreshToken(refreshToken: string): {
376
376
  // CLIENT VALIDATION
377
377
  // ============================================================================
378
378
 
379
+ /**
380
+ * Check if a string is a valid HTTPS URL (for Client ID Metadata Documents)
381
+ */
382
+ function isHttpsUrl(str: string): boolean {
383
+ try {
384
+ const url = new URL(str);
385
+ return url.protocol === 'https:';
386
+ } catch {
387
+ return false;
388
+ }
389
+ }
390
+
379
391
  /**
380
392
  * Validate OAuth client and redirect URI
393
+ *
394
+ * Supports:
395
+ * 1. Pre-registered clients (e.g., 'netpad')
396
+ * 2. Client ID Metadata Documents (HTTPS URLs as client_id per MCP spec)
381
397
  */
382
398
  export function validateClient(
383
399
  clientId: string,
384
400
  redirectUri: string
385
- ): { valid: boolean; error?: string } {
401
+ ): { valid: boolean; error?: string; isMetadataDocument?: boolean } {
402
+ // First check pre-registered clients
386
403
  const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
387
404
 
388
- if (!client) {
389
- return { valid: false, error: 'invalid_client' };
405
+ if (client) {
406
+ if (!client.redirectUris.includes(redirectUri)) {
407
+ return { valid: false, error: 'invalid_redirect_uri' };
408
+ }
409
+ return { valid: true, isMetadataDocument: false };
390
410
  }
391
411
 
392
- if (!client.redirectUris.includes(redirectUri)) {
393
- return { valid: false, error: 'invalid_redirect_uri' };
412
+ // Check if client_id is a URL (Client ID Metadata Document)
413
+ // Per MCP spec, we accept HTTPS URLs as client_id
414
+ if (isHttpsUrl(clientId)) {
415
+ // For Client ID Metadata Documents, we validate:
416
+ // 1. The redirect_uri should be localhost or HTTPS
417
+ // 2. The client_id URL must have a path component
418
+
419
+ try {
420
+ const clientUrl = new URL(clientId);
421
+ // Per spec: client_id URL MUST contain a path component
422
+ if (!clientUrl.pathname || clientUrl.pathname === '/') {
423
+ console.log('[OAuth] Client ID Metadata Document URL must have a path component');
424
+ return { valid: false, error: 'invalid_client' };
425
+ }
426
+
427
+ const redirectUrl = new URL(redirectUri);
428
+ const isLocalhost = redirectUrl.hostname === 'localhost' ||
429
+ redirectUrl.hostname === '127.0.0.1' ||
430
+ redirectUrl.hostname === '[::1]';
431
+ const isHttps = redirectUrl.protocol === 'https:';
432
+
433
+ if (!isLocalhost && !isHttps) {
434
+ console.log('[OAuth] Redirect URI must be localhost or HTTPS');
435
+ return { valid: false, error: 'invalid_redirect_uri' };
436
+ }
437
+
438
+ // Accept the client - full validation of redirect_uri against the
439
+ // metadata document would require fetching it (async), which we'll do
440
+ // in the authorize endpoint if needed
441
+ console.log('[OAuth] Accepting Client ID Metadata Document:', clientId);
442
+ return { valid: true, isMetadataDocument: true };
443
+ } catch {
444
+ return { valid: false, error: 'invalid_redirect_uri' };
445
+ }
394
446
  }
395
447
 
396
- return { valid: true };
448
+ return { valid: false, error: 'invalid_client' };
397
449
  }
398
450
 
399
451
  /**
400
452
  * Validate requested scopes
453
+ *
454
+ * For Client ID Metadata Documents, we accept all supported scopes
401
455
  */
402
456
  export function validateScopes(requestedScopes: string, clientId: string): string[] {
403
457
  const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
404
- if (!client) return [];
405
458
 
406
- const requested = requestedScopes.split(' ').filter(Boolean);
407
- const allowed = requested.filter(scope => client.scopes.includes(scope));
459
+ // For pre-registered clients, filter to allowed scopes
460
+ if (client) {
461
+ const requested = requestedScopes.split(' ').filter(Boolean);
462
+ const allowed = requested.filter(scope => client.scopes.includes(scope));
463
+ return allowed.length > 0 ? allowed : ['mcp'];
464
+ }
465
+
466
+ // For Client ID Metadata Documents (URL-based client_id), accept all supported scopes
467
+ if (isHttpsUrl(clientId)) {
468
+ const requested = requestedScopes.split(' ').filter(Boolean);
469
+ const allScopes = Object.keys(OAUTH_CONFIG.scopes);
470
+ const allowed = requested.filter(scope => allScopes.includes(scope));
471
+ return allowed.length > 0 ? allowed : ['mcp'];
472
+ }
408
473
 
409
- // Default to 'mcp' if no valid scopes
410
- return allowed.length > 0 ? allowed : ['mcp'];
474
+ return ['mcp'];
411
475
  }
package/api/mcp.ts CHANGED
@@ -3,6 +3,13 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
3
3
  import { IncomingMessage, ServerResponse } from 'node:http';
4
4
  import { createNetPadMcpServer } from '@netpad/mcp-server';
5
5
  import { validateAccessToken } from './lib/oauth.js';
6
+ import {
7
+ recordMetric,
8
+ startMetricsContext,
9
+ getDuration,
10
+ extractToolFromBody,
11
+ flushMetrics,
12
+ } from './lib/metrics.js';
6
13
 
7
14
  // Store transports by session ID for reconnection
8
15
  const transports = new Map<string, StreamableHTTPServerTransport>();
@@ -168,7 +175,7 @@ async function validateApiKey(apiKey: string): Promise<AuthResult> {
168
175
  });
169
176
 
170
177
  if (response.ok) {
171
- const data = await response.json();
178
+ const data = await response.json() as { userId?: string; organizationId?: string };
172
179
  // Cache the valid result
173
180
  apiKeyCache.set(apiKey, {
174
181
  valid: true,
@@ -189,7 +196,7 @@ async function validateApiKey(apiKey: string): Promise<AuthResult> {
189
196
  });
190
197
 
191
198
  // Parse error response
192
- const errorData = await response.json().catch(() => ({}));
199
+ const errorData = await response.json().catch(() => ({})) as { error?: { message?: string; code?: string } };
193
200
  return {
194
201
  valid: false,
195
202
  error: {
@@ -211,6 +218,8 @@ async function validateApiKey(apiKey: string): Promise<AuthResult> {
211
218
 
212
219
  // Main handler
213
220
  export default async function handler(req: VercelRequest, res: VercelResponse) {
221
+ const metricsContext = startMetricsContext();
222
+
214
223
  // Handle CORS preflight
215
224
  if (req.method === 'OPTIONS') {
216
225
  res.status(200).end();
@@ -222,6 +231,26 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
222
231
  // ============================================================================
223
232
  const authResult = await validateRequest(req);
224
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
+ );
225
254
  res.status(authResult.error?.status || 401).json({
226
255
  error: authResult.error?.error || 'Authentication failed',
227
256
  code: authResult.error?.code || 'AUTH_FAILED',
@@ -230,6 +259,11 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
230
259
  return;
231
260
  }
232
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
+
233
267
  // Get session ID from headers
234
268
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
235
269
 
@@ -252,6 +286,21 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
252
286
  transports.set(transport.sessionId, transport);
253
287
  }
254
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
+
255
304
  await transport.handleRequest(
256
305
  req as unknown as IncomingMessage,
257
306
  res as unknown as ServerResponse
@@ -261,6 +310,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
261
310
 
262
311
  // Handle POST requests
263
312
  if (req.method === 'POST') {
313
+ // Extract tool name from request body for metrics
314
+ const tool = extractToolFromBody(req.body);
315
+
264
316
  // Try to reuse existing transport for this session
265
317
  let transport = sessionId ? transports.get(sessionId) : undefined;
266
318
 
@@ -287,9 +339,34 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
287
339
  res as unknown as ServerResponse,
288
340
  req.body
289
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(() => {});
290
358
  return;
291
359
  }
292
360
 
293
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(() => {});
294
371
  res.status(405).json({ error: 'Method not allowed' });
295
372
  }
@@ -50,6 +50,10 @@ export default function handler(req: VercelRequest, res: VercelResponse) {
50
50
  // OPTIONAL: PKCE code challenge methods (RFC 7636)
51
51
  code_challenge_methods_supported: ['S256', 'plain'],
52
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
+
53
57
  // OPTIONAL: Service documentation
54
58
  service_documentation: 'https://docs.netpad.io/docs/developer/mcp-server',
55
59
 
package/api/token.ts CHANGED
@@ -18,8 +18,11 @@ import {
18
18
  generateRefreshToken,
19
19
  exchangeRefreshToken,
20
20
  } from './lib/oauth.js';
21
+ import { recordMetric, startMetricsContext, getDuration } from './lib/metrics.js';
21
22
 
22
23
  export default async function handler(req: VercelRequest, res: VercelResponse) {
24
+ const metricsContext = startMetricsContext();
25
+
23
26
  // Set CORS headers for token endpoint
24
27
  res.setHeader('Access-Control-Allow-Origin', '*');
25
28
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
@@ -35,6 +38,15 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
35
38
 
36
39
  // Only POST is allowed
37
40
  if (req.method !== 'POST') {
41
+ recordMetric({
42
+ method: req.method as 'GET' | 'POST',
43
+ tool: 'oauth/token',
44
+ authMethod: 'none',
45
+ authSuccess: false,
46
+ durationMs: getDuration(metricsContext),
47
+ statusCode: 405,
48
+ errorCode: 'METHOD_NOT_ALLOWED',
49
+ });
38
50
  res.status(405).json({ error: 'method_not_allowed' });
39
51
  return;
40
52
  }
@@ -159,6 +171,18 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
159
171
  scope: authCode.scope,
160
172
  });
161
173
 
174
+ // Record successful token exchange
175
+ recordMetric({
176
+ method: 'POST',
177
+ organizationId: authCode.organizationId,
178
+ userId: authCode.userId,
179
+ tool: 'oauth/token/authorization_code',
180
+ authMethod: 'oauth',
181
+ authSuccess: true,
182
+ durationMs: getDuration(metricsContext),
183
+ statusCode: 200,
184
+ });
185
+
162
186
  // Return token response
163
187
  console.log('[Token] SUCCESS - returning tokens');
164
188
  res.status(200).json({
@@ -193,6 +217,16 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
193
217
  return;
194
218
  }
195
219
 
220
+ // Record successful refresh token exchange
221
+ recordMetric({
222
+ method: 'POST',
223
+ tool: 'oauth/token/refresh',
224
+ authMethod: 'oauth',
225
+ authSuccess: true,
226
+ durationMs: getDuration(metricsContext),
227
+ statusCode: 200,
228
+ });
229
+
196
230
  res.status(200).json({
197
231
  access_token: newTokens.accessToken,
198
232
  token_type: 'Bearer',
@@ -206,6 +240,15 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
206
240
  // ============================================================================
207
241
  // Unsupported Grant Type
208
242
  // ============================================================================
243
+ recordMetric({
244
+ method: 'POST',
245
+ tool: 'oauth/token',
246
+ authMethod: 'none',
247
+ authSuccess: false,
248
+ durationMs: getDuration(metricsContext),
249
+ statusCode: 400,
250
+ errorCode: 'UNSUPPORTED_GRANT_TYPE',
251
+ });
209
252
  res.status(400).json({
210
253
  error: 'unsupported_grant_type',
211
254
  error_description: 'Only authorization_code and refresh_token grants are supported',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netpad/mcp-server-remote",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
4
4
  "description": "Remote MCP server for NetPad - deployable to Vercel for Claude custom connectors. Includes all 80+ tools from @netpad/mcp-server.",
5
5
  "author": "Michael Lynn",
6
6
  "license": "Apache-2.0",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@modelcontextprotocol/sdk": "^1.0.0",
15
- "@netpad/mcp-server": "^2.3.0",
15
+ "@netpad/mcp-server": "^2.4.0",
16
16
  "zod": "^3.23.0"
17
17
  },
18
18
  "devDependencies": {