@netpad/mcp-server-remote 1.4.2 → 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/authorize.ts +1 -1
- package/api/lib/metrics.ts +208 -0
- package/api/lib/oauth.ts +75 -11
- package/api/mcp.ts +79 -2
- package/api/oauth-metadata.ts +4 -0
- package/api/token.ts +43 -0
- package/netpad-mcp-server-remote-1.5.0.tgz +0 -0
- package/package.json +2 -2
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 (
|
|
389
|
-
|
|
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 (
|
|
393
|
-
|
|
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:
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
}
|
package/api/oauth-metadata.ts
CHANGED
|
@@ -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',
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netpad/mcp-server-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
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.
|
|
15
|
+
"@netpad/mcp-server": "^2.4.0",
|
|
16
16
|
"zod": "^3.23.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|