@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,411 @@
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
+ * Validate OAuth client and redirect URI
381
+ */
382
+ export function validateClient(
383
+ clientId: string,
384
+ redirectUri: string
385
+ ): { valid: boolean; error?: string } {
386
+ const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
387
+
388
+ if (!client) {
389
+ return { valid: false, error: 'invalid_client' };
390
+ }
391
+
392
+ if (!client.redirectUris.includes(redirectUri)) {
393
+ return { valid: false, error: 'invalid_redirect_uri' };
394
+ }
395
+
396
+ return { valid: true };
397
+ }
398
+
399
+ /**
400
+ * Validate requested scopes
401
+ */
402
+ export function validateScopes(requestedScopes: string, clientId: string): string[] {
403
+ const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
404
+ if (!client) return [];
405
+
406
+ const requested = requestedScopes.split(' ').filter(Boolean);
407
+ const allowed = requested.filter(scope => client.scopes.includes(scope));
408
+
409
+ // Default to 'mcp' if no valid scopes
410
+ return allowed.length > 0 ? allowed : ['mcp'];
411
+ }
package/api/mcp.ts CHANGED
@@ -2,12 +2,13 @@ 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';
5
6
 
6
7
  // Store transports by session ID for reconnection
7
8
  const transports = new Map<string, StreamableHTTPServerTransport>();
8
9
 
9
10
  // ============================================================================
10
- // API KEY AUTHENTICATION
11
+ // AUTHENTICATION (Supports both OAuth tokens and API keys)
11
12
  // ============================================================================
12
13
 
13
14
  // Cache validated API keys for 5 minutes to reduce API calls
@@ -16,28 +17,46 @@ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
16
17
 
17
18
  /**
18
19
  * Get the NetPad API base URL from environment or default
20
+ * Note: Using www.netpad.io because netpad.io redirects to www with 307
19
21
  */
20
22
  function getNetPadApiUrl(): string {
21
- return process.env.NETPAD_API_URL || 'https://netpad.io';
23
+ return process.env.NETPAD_API_URL || 'https://www.netpad.io';
22
24
  }
23
25
 
24
26
  /**
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
27
+ * Authentication result
28
+ */
29
+ interface AuthResult {
30
+ valid: boolean;
31
+ userId?: string;
32
+ organizationId?: string;
33
+ scope?: string;
34
+ error?: {
35
+ status: number;
36
+ error: string;
37
+ code: string;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Validate the request using either OAuth token or API key
29
43
  *
30
- * @returns null if valid, or an error response object if invalid
44
+ * Supports:
45
+ * 1. OAuth access tokens (JWT-like tokens from /token endpoint)
46
+ * 2. NetPad API keys (np_live_xxx or np_test_xxx)
31
47
  */
32
- async function validateApiKey(req: VercelRequest): Promise<{ status: number; error: string; code: string } | null> {
48
+ async function validateRequest(req: VercelRequest): Promise<AuthResult> {
33
49
  const authHeader = req.headers['authorization'];
34
50
 
35
51
  // Check if Authorization header is present
36
52
  if (!authHeader) {
37
53
  return {
38
- status: 401,
39
- error: 'Missing Authorization header. Use: Authorization: Bearer np_live_xxx',
40
- code: 'MISSING_API_KEY',
54
+ valid: false,
55
+ error: {
56
+ status: 401,
57
+ error: 'Missing Authorization header. Use: Authorization: Bearer <token>',
58
+ code: 'MISSING_AUTH',
59
+ },
41
60
  };
42
61
  }
43
62
 
@@ -45,41 +64,94 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
45
64
  const parts = authHeader.split(' ');
46
65
  if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
47
66
  return {
48
- status: 401,
49
- error: 'Invalid Authorization header format. Use: Authorization: Bearer np_live_xxx',
50
- code: 'INVALID_AUTH_FORMAT',
67
+ valid: false,
68
+ error: {
69
+ status: 401,
70
+ error: 'Invalid Authorization header format. Use: Authorization: Bearer <token>',
71
+ code: 'INVALID_AUTH_FORMAT',
72
+ },
51
73
  };
52
74
  }
53
75
 
54
- const apiKey = parts[1].trim();
76
+ const token = parts[1].trim();
55
77
 
56
- if (!apiKey) {
78
+ if (!token) {
57
79
  return {
58
- status: 401,
59
- error: 'API key is empty',
60
- code: 'EMPTY_API_KEY',
80
+ valid: false,
81
+ error: {
82
+ status: 401,
83
+ error: 'Token is empty',
84
+ code: 'EMPTY_TOKEN',
85
+ },
61
86
  };
62
87
  }
63
88
 
64
- // Validate key format (must start with np_live_ or np_test_)
65
- if (!apiKey.startsWith('np_live_') && !apiKey.startsWith('np_test_')) {
89
+ // ============================================================================
90
+ // Try OAuth token first (JWT-like format with dots)
91
+ // ============================================================================
92
+ if (token.includes('.')) {
93
+ console.log('[MCP] Attempting OAuth token validation...');
94
+ const payload = validateAccessToken(token);
95
+ if (payload) {
96
+ console.log('[MCP] OAuth token valid:', { userId: payload.sub, org: payload.org });
97
+ return {
98
+ valid: true,
99
+ userId: payload.sub,
100
+ organizationId: payload.org,
101
+ scope: payload.scope,
102
+ };
103
+ }
104
+ // If it looks like a JWT but failed validation, return error
105
+ // (don't fall through to API key validation)
106
+ console.log('[MCP] OAuth token validation failed');
66
107
  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',
108
+ valid: false,
109
+ error: {
110
+ status: 401,
111
+ error: 'Invalid or expired OAuth token',
112
+ code: 'INVALID_OAUTH_TOKEN',
113
+ },
70
114
  };
71
115
  }
72
116
 
117
+ // ============================================================================
118
+ // Try API key (np_live_xxx or np_test_xxx format)
119
+ // ============================================================================
120
+ if (token.startsWith('np_live_') || token.startsWith('np_test_')) {
121
+ return validateApiKey(token);
122
+ }
123
+
124
+ // Unknown token format
125
+ return {
126
+ valid: false,
127
+ error: {
128
+ status: 401,
129
+ error: 'Invalid token format. Use an OAuth token or NetPad API key (np_live_xxx)',
130
+ code: 'INVALID_TOKEN_FORMAT',
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Validate a NetPad API key
137
+ */
138
+ async function validateApiKey(apiKey: string): Promise<AuthResult> {
73
139
  // Check cache first
74
140
  const cached = apiKeyCache.get(apiKey);
75
141
  if (cached && cached.expiresAt > Date.now()) {
76
142
  if (cached.valid) {
77
- return null; // Valid key from cache
143
+ return {
144
+ valid: true,
145
+ organizationId: cached.organizationId,
146
+ };
78
147
  }
79
148
  return {
80
- status: 401,
81
- error: 'Invalid or expired API key',
82
- code: 'INVALID_API_KEY',
149
+ valid: false,
150
+ error: {
151
+ status: 401,
152
+ error: 'Invalid or expired API key',
153
+ code: 'INVALID_API_KEY',
154
+ },
83
155
  };
84
156
  }
85
157
 
@@ -103,7 +175,11 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
103
175
  organizationId: data.organizationId,
104
176
  expiresAt: Date.now() + CACHE_TTL_MS,
105
177
  });
106
- return null; // Valid key
178
+ return {
179
+ valid: true,
180
+ userId: data.userId,
181
+ organizationId: data.organizationId,
182
+ };
107
183
  }
108
184
 
109
185
  // Key is invalid - cache the negative result too (shorter TTL)
@@ -115,9 +191,12 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
115
191
  // Parse error response
116
192
  const errorData = await response.json().catch(() => ({}));
117
193
  return {
118
- status: response.status,
119
- error: errorData.error?.message || 'Invalid or expired API key',
120
- code: errorData.error?.code || 'INVALID_API_KEY',
194
+ valid: false,
195
+ error: {
196
+ status: response.status,
197
+ error: errorData.error?.message || 'Invalid or expired API key',
198
+ code: errorData.error?.code || 'INVALID_API_KEY',
199
+ },
121
200
  };
122
201
  } catch (error) {
123
202
  console.error('Error validating API key against NetPad:', error);
@@ -126,7 +205,7 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
126
205
  // This allows the MCP server to work even if NetPad API is temporarily unavailable
127
206
  // but only accepts properly formatted keys
128
207
  console.warn('NetPad API unavailable, accepting key based on format validation only');
129
- return null;
208
+ return { valid: true };
130
209
  }
131
210
  }
132
211
 
@@ -141,12 +220,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
141
220
  // ============================================================================
142
221
  // AUTHENTICATE REQUEST
143
222
  // ============================================================================
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".',
223
+ const authResult = await validateRequest(req);
224
+ if (!authResult.valid) {
225
+ res.status(authResult.error?.status || 401).json({
226
+ error: authResult.error?.error || 'Authentication failed',
227
+ code: authResult.error?.code || 'AUTH_FAILED',
228
+ hint: 'Connect via Claude.ai Settings > Connectors, or use an API key from netpad.io/settings',
150
229
  });
151
230
  return;
152
231
  }
@@ -164,7 +243,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
164
243
  // Create the full NetPad MCP server with all 80+ tools
165
244
  const server = createNetPadMcpServer({
166
245
  name: '@netpad/mcp-server-remote',
167
- version: '1.1.0',
246
+ version: '1.2.0',
168
247
  });
169
248
  await server.connect(transport);
170
249
 
@@ -194,7 +273,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
194
273
  // Create the full NetPad MCP server with all 80+ tools
195
274
  const server = createNetPadMcpServer({
196
275
  name: '@netpad/mcp-server-remote',
197
- version: '1.1.0',
276
+ version: '1.2.0',
198
277
  });
199
278
  await server.connect(transport);
200
279