@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.
@@ -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
+ }
@@ -0,0 +1,475 @@
1
+ /**
2
+ * OAuth 2.0 + PKCE utilities for NetPad MCP Remote Server
3
+ *
4
+ * This implements the OAuth 2.0 Authorization Code flow with PKCE
5
+ * for Claude.ai's Remote MCP connector integration.
6
+ */
7
+
8
+ import crypto from 'crypto';
9
+
10
+ // ============================================================================
11
+ // CONFIGURATION
12
+ // ============================================================================
13
+
14
+ export const OAUTH_CONFIG = {
15
+ // Issuer URL (the MCP server itself)
16
+ issuer: process.env.OAUTH_ISSUER || 'https://mcp.netpad.io',
17
+
18
+ // NetPad API URL for user authentication
19
+ // Note: Using www.netpad.io because netpad.io redirects to www with 307
20
+ netpadApiUrl: process.env.NETPAD_API_URL || 'https://www.netpad.io',
21
+
22
+ // Token settings
23
+ accessTokenTtlSeconds: 3600, // 1 hour
24
+ refreshTokenTtlSeconds: 86400 * 30, // 30 days
25
+ authCodeTtlSeconds: 600, // 10 minutes
26
+
27
+ // Allowed OAuth clients
28
+ allowedClients: {
29
+ 'netpad': {
30
+ name: 'NetPad MCP Connector',
31
+ redirectUris: [
32
+ 'https://claude.ai/api/mcp/auth_callback',
33
+ 'https://www.claude.ai/api/mcp/auth_callback',
34
+ // Add localhost for testing
35
+ 'http://localhost:3000/api/mcp/auth_callback',
36
+ ],
37
+ scopes: ['claudeai', 'mcp', 'read', 'write'],
38
+ pkceRequired: true,
39
+ },
40
+ },
41
+
42
+ // Supported scopes
43
+ scopes: {
44
+ 'claudeai': 'Access NetPad via Claude.ai',
45
+ 'mcp': 'Use MCP tools',
46
+ 'read': 'Read forms, workflows, and data',
47
+ 'write': 'Create and modify forms, workflows, and data',
48
+ },
49
+ };
50
+
51
+ // ============================================================================
52
+ // TYPES
53
+ // ============================================================================
54
+
55
+ export interface AuthorizationCode {
56
+ code: string;
57
+ clientId: string;
58
+ redirectUri: string;
59
+ scope: string;
60
+ codeChallenge: string;
61
+ codeChallengeMethod: string;
62
+ userId: string;
63
+ organizationId: string;
64
+ expiresAt: number;
65
+ }
66
+
67
+ export interface OAuthToken {
68
+ accessToken: string;
69
+ refreshToken: string;
70
+ tokenType: 'Bearer';
71
+ expiresIn: number;
72
+ scope: string;
73
+ }
74
+
75
+ export interface TokenPayload {
76
+ sub: string; // User ID
77
+ org: string; // Organization ID
78
+ scope: string;
79
+ iat: number;
80
+ exp: number;
81
+ iss: string;
82
+ aud: string;
83
+ }
84
+
85
+ // ============================================================================
86
+ // IN-MEMORY STORES (Replace with database in production)
87
+ // ============================================================================
88
+
89
+ // Store authorization codes (short-lived, should use Redis in production)
90
+ const authorizationCodes = new Map<string, AuthorizationCode>();
91
+
92
+ // Store refresh tokens (should use database in production)
93
+ const refreshTokens = new Map<string, { userId: string; organizationId: string; scope: string; expiresAt: number }>();
94
+
95
+ // Cleanup expired codes periodically
96
+ setInterval(() => {
97
+ const now = Date.now();
98
+ for (const [code, data] of authorizationCodes) {
99
+ if (data.expiresAt < now) {
100
+ authorizationCodes.delete(code);
101
+ }
102
+ }
103
+ for (const [token, data] of refreshTokens) {
104
+ if (data.expiresAt < now) {
105
+ refreshTokens.delete(token);
106
+ }
107
+ }
108
+ }, 60000); // Every minute
109
+
110
+ // ============================================================================
111
+ // PKCE UTILITIES
112
+ // ============================================================================
113
+
114
+ /**
115
+ * Verify PKCE code_verifier against code_challenge
116
+ *
117
+ * For S256: code_challenge = BASE64URL(SHA256(code_verifier))
118
+ */
119
+ export function verifyPkceChallenge(
120
+ codeVerifier: string,
121
+ codeChallenge: string,
122
+ method: string
123
+ ): boolean {
124
+ if (method === 'S256') {
125
+ const hash = crypto.createHash('sha256').update(codeVerifier).digest();
126
+ const computed = base64UrlEncode(hash);
127
+ return computed === codeChallenge;
128
+ } else if (method === 'plain') {
129
+ return codeVerifier === codeChallenge;
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * Base64 URL encode (RFC 4648)
136
+ */
137
+ function base64UrlEncode(buffer: Buffer): string {
138
+ return buffer
139
+ .toString('base64')
140
+ .replace(/\+/g, '-')
141
+ .replace(/\//g, '_')
142
+ .replace(/=+$/, '');
143
+ }
144
+
145
+ // ============================================================================
146
+ // AUTHORIZATION CODE MANAGEMENT
147
+ // ============================================================================
148
+
149
+ /**
150
+ * Generate a self-contained authorization code (stateless)
151
+ *
152
+ * Since Vercel serverless functions don't share memory between invocations,
153
+ * we encode all the authorization data directly into the code itself.
154
+ * The code is signed with HMAC to prevent tampering.
155
+ */
156
+ export function generateAuthorizationCode(params: {
157
+ clientId: string;
158
+ redirectUri: string;
159
+ scope: string;
160
+ codeChallenge: string;
161
+ codeChallengeMethod: string;
162
+ userId: string;
163
+ organizationId: string;
164
+ }): string {
165
+ const payload = {
166
+ cid: params.clientId,
167
+ ruri: params.redirectUri,
168
+ scope: params.scope,
169
+ cc: params.codeChallenge,
170
+ ccm: params.codeChallengeMethod,
171
+ uid: params.userId,
172
+ oid: params.organizationId,
173
+ exp: Date.now() + OAUTH_CONFIG.authCodeTtlSeconds * 1000,
174
+ nonce: crypto.randomBytes(8).toString('hex'), // Prevent replay
175
+ };
176
+
177
+ const payloadStr = JSON.stringify(payload);
178
+ const payloadB64 = Buffer.from(payloadStr).toString('base64url');
179
+
180
+ // Sign the payload
181
+ const secret = process.env.OAUTH_SECRET || 'netpad-mcp-oauth-secret-change-in-production';
182
+ const signature = crypto
183
+ .createHmac('sha256', secret)
184
+ .update(payloadB64)
185
+ .digest('base64url');
186
+
187
+ return `${payloadB64}.${signature}`;
188
+ }
189
+
190
+ /**
191
+ * Consume an authorization code (stateless verification)
192
+ */
193
+ export function consumeAuthorizationCode(code: string): AuthorizationCode | null {
194
+ try {
195
+ const parts = code.split('.');
196
+ if (parts.length !== 2) {
197
+ return null;
198
+ }
199
+
200
+ const [payloadB64, signature] = parts;
201
+
202
+ // Verify signature
203
+ const secret = process.env.OAUTH_SECRET || 'netpad-mcp-oauth-secret-change-in-production';
204
+ const expectedSig = crypto
205
+ .createHmac('sha256', secret)
206
+ .update(payloadB64)
207
+ .digest('base64url');
208
+
209
+ if (signature !== expectedSig) {
210
+ console.log('[OAuth] Authorization code signature mismatch');
211
+ return null;
212
+ }
213
+
214
+ // Decode payload
215
+ const payloadStr = Buffer.from(payloadB64, 'base64url').toString();
216
+ const payload = JSON.parse(payloadStr);
217
+
218
+ // Check expiration
219
+ if (payload.exp < Date.now()) {
220
+ console.log('[OAuth] Authorization code expired');
221
+ return null;
222
+ }
223
+
224
+ // Note: In a stateless system, we can't truly enforce one-time use
225
+ // without external storage. The short expiration (10 min) mitigates this.
226
+
227
+ return {
228
+ code,
229
+ clientId: payload.cid,
230
+ redirectUri: payload.ruri,
231
+ scope: payload.scope,
232
+ codeChallenge: payload.cc,
233
+ codeChallengeMethod: payload.ccm,
234
+ userId: payload.uid,
235
+ organizationId: payload.oid,
236
+ expiresAt: payload.exp,
237
+ };
238
+ } catch (error) {
239
+ console.error('[OAuth] Failed to decode authorization code:', error);
240
+ return null;
241
+ }
242
+ }
243
+
244
+ // ============================================================================
245
+ // TOKEN GENERATION
246
+ // ============================================================================
247
+
248
+ /**
249
+ * Generate access token (JWT-like but simpler for now)
250
+ *
251
+ * In production, use proper JWT with RS256 signing
252
+ */
253
+ export function generateAccessToken(params: {
254
+ userId: string;
255
+ organizationId: string;
256
+ scope: string;
257
+ }): string {
258
+ const payload: TokenPayload = {
259
+ sub: params.userId,
260
+ org: params.organizationId,
261
+ scope: params.scope,
262
+ iat: Math.floor(Date.now() / 1000),
263
+ exp: Math.floor(Date.now() / 1000) + OAUTH_CONFIG.accessTokenTtlSeconds,
264
+ iss: OAUTH_CONFIG.issuer,
265
+ aud: 'mcp.netpad.io',
266
+ };
267
+
268
+ // Simple encoding (in production, use proper JWT signing)
269
+ const header = base64UrlEncode(Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
270
+ const body = base64UrlEncode(Buffer.from(JSON.stringify(payload)));
271
+ const secret = process.env.OAUTH_SECRET || 'netpad-mcp-oauth-secret-change-in-production';
272
+ const signature = crypto
273
+ .createHmac('sha256', secret)
274
+ .update(`${header}.${body}`)
275
+ .digest();
276
+ const sig = base64UrlEncode(signature);
277
+
278
+ return `${header}.${body}.${sig}`;
279
+ }
280
+
281
+ /**
282
+ * Generate refresh token
283
+ */
284
+ export function generateRefreshToken(params: {
285
+ userId: string;
286
+ organizationId: string;
287
+ scope: string;
288
+ }): string {
289
+ const token = crypto.randomBytes(32).toString('hex');
290
+
291
+ refreshTokens.set(token, {
292
+ userId: params.userId,
293
+ organizationId: params.organizationId,
294
+ scope: params.scope,
295
+ expiresAt: Date.now() + OAUTH_CONFIG.refreshTokenTtlSeconds * 1000,
296
+ });
297
+
298
+ return token;
299
+ }
300
+
301
+ /**
302
+ * Validate and decode an access token
303
+ */
304
+ export function validateAccessToken(token: string): TokenPayload | null {
305
+ try {
306
+ const parts = token.split('.');
307
+ if (parts.length !== 3) {
308
+ return null;
309
+ }
310
+
311
+ const [header, body, sig] = parts;
312
+
313
+ // Verify signature
314
+ const secret = process.env.OAUTH_SECRET || 'netpad-mcp-oauth-secret-change-in-production';
315
+ const expectedSig = base64UrlEncode(
316
+ crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest()
317
+ );
318
+
319
+ if (sig !== expectedSig) {
320
+ return null;
321
+ }
322
+
323
+ // Decode payload
324
+ const payload = JSON.parse(Buffer.from(body, 'base64').toString()) as TokenPayload;
325
+
326
+ // Check expiration
327
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
328
+ return null;
329
+ }
330
+
331
+ return payload;
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Exchange refresh token for new access token
339
+ */
340
+ export function exchangeRefreshToken(refreshToken: string): {
341
+ accessToken: string;
342
+ refreshToken: string;
343
+ expiresIn: number;
344
+ scope: string;
345
+ } | null {
346
+ const data = refreshTokens.get(refreshToken);
347
+
348
+ if (!data || data.expiresAt < Date.now()) {
349
+ return null;
350
+ }
351
+
352
+ // Generate new tokens
353
+ const newAccessToken = generateAccessToken({
354
+ userId: data.userId,
355
+ organizationId: data.organizationId,
356
+ scope: data.scope,
357
+ });
358
+
359
+ // Optionally rotate refresh token
360
+ refreshTokens.delete(refreshToken);
361
+ const newRefreshToken = generateRefreshToken({
362
+ userId: data.userId,
363
+ organizationId: data.organizationId,
364
+ scope: data.scope,
365
+ });
366
+
367
+ return {
368
+ accessToken: newAccessToken,
369
+ refreshToken: newRefreshToken,
370
+ expiresIn: OAUTH_CONFIG.accessTokenTtlSeconds,
371
+ scope: data.scope,
372
+ };
373
+ }
374
+
375
+ // ============================================================================
376
+ // CLIENT VALIDATION
377
+ // ============================================================================
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
+
391
+ /**
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)
397
+ */
398
+ export function validateClient(
399
+ clientId: string,
400
+ redirectUri: string
401
+ ): { valid: boolean; error?: string; isMetadataDocument?: boolean } {
402
+ // First check pre-registered clients
403
+ const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
404
+
405
+ if (client) {
406
+ if (!client.redirectUris.includes(redirectUri)) {
407
+ return { valid: false, error: 'invalid_redirect_uri' };
408
+ }
409
+ return { valid: true, isMetadataDocument: false };
410
+ }
411
+
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
+ }
446
+ }
447
+
448
+ return { valid: false, error: 'invalid_client' };
449
+ }
450
+
451
+ /**
452
+ * Validate requested scopes
453
+ *
454
+ * For Client ID Metadata Documents, we accept all supported scopes
455
+ */
456
+ export function validateScopes(requestedScopes: string, clientId: string): string[] {
457
+ const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
458
+
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
+ }
473
+
474
+ return ['mcp'];
475
+ }