@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.
- package/api/.well-known/oauth-authorization-server.ts +71 -0
- package/api/authorize.ts +538 -0
- package/api/index.ts +27 -4
- package/api/lib/oauth.ts +411 -0
- package/api/mcp.ts +120 -41
- package/api/oauth-metadata.ts +71 -0
- package/api/oauth-protected-resource.ts +40 -0
- package/api/token.ts +213 -0
- package/netpad-mcp-server-remote-1.3.0.tgz +0 -0
- package/netpad-mcp-server-remote-1.4.1.tgz +0 -0
- package/netpad-mcp-server-remote-1.4.2.tgz +0 -0
- package/package.json +2 -2
- package/vercel.json +20 -0
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
// OPTIONAL: Service documentation
|
|
54
|
+
service_documentation: 'https://docs.netpad.io/docs/developer/mcp-server',
|
|
55
|
+
|
|
56
|
+
// OPTIONAL: MCP-specific metadata
|
|
57
|
+
mcp_endpoint: `${issuer}/mcp`,
|
|
58
|
+
|
|
59
|
+
// Custom: NetPad-specific info
|
|
60
|
+
netpad: {
|
|
61
|
+
name: 'NetPad MCP Server',
|
|
62
|
+
version: '1.2.0',
|
|
63
|
+
tools_count: '80+',
|
|
64
|
+
api_key_url: 'https://netpad.io/settings',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
res.setHeader('Content-Type', 'application/json');
|
|
69
|
+
res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
|
|
70
|
+
res.status(200).json(metadata);
|
|
71
|
+
}
|
package/api/authorize.ts
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 Authorization Endpoint
|
|
3
|
+
*
|
|
4
|
+
* GET /authorize - Initiates the OAuth flow
|
|
5
|
+
*
|
|
6
|
+
* This endpoint:
|
|
7
|
+
* 1. Validates the OAuth client and redirect URI
|
|
8
|
+
* 2. Redirects to NetPad login if user is not authenticated
|
|
9
|
+
* 3. Shows consent screen (or auto-approves for trusted clients)
|
|
10
|
+
* 4. Redirects back to Claude.ai with an authorization code
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
14
|
+
import {
|
|
15
|
+
OAUTH_CONFIG,
|
|
16
|
+
validateClient,
|
|
17
|
+
validateScopes,
|
|
18
|
+
generateAuthorizationCode,
|
|
19
|
+
} from './lib/oauth.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate the HTML for the authorization page
|
|
23
|
+
*/
|
|
24
|
+
function generateAuthorizationPage(params: {
|
|
25
|
+
clientId: string;
|
|
26
|
+
redirectUri: string;
|
|
27
|
+
scope: string;
|
|
28
|
+
state: string;
|
|
29
|
+
codeChallenge: string;
|
|
30
|
+
codeChallengeMethod: string;
|
|
31
|
+
error?: string;
|
|
32
|
+
}): string {
|
|
33
|
+
const netpadUrl = OAUTH_CONFIG.netpadApiUrl;
|
|
34
|
+
const scopes = params.scope.split(' ').filter(Boolean);
|
|
35
|
+
|
|
36
|
+
return `<!DOCTYPE html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="UTF-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
41
|
+
<title>Authorize NetPad - Claude.ai</title>
|
|
42
|
+
<style>
|
|
43
|
+
* {
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
margin: 0;
|
|
46
|
+
padding: 0;
|
|
47
|
+
}
|
|
48
|
+
body {
|
|
49
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
50
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
51
|
+
min-height: 100vh;
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
padding: 20px;
|
|
56
|
+
}
|
|
57
|
+
.container {
|
|
58
|
+
background: white;
|
|
59
|
+
border-radius: 16px;
|
|
60
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
61
|
+
max-width: 420px;
|
|
62
|
+
width: 100%;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
}
|
|
65
|
+
.header {
|
|
66
|
+
background: linear-gradient(135deg, #00d9a5 0%, #00b894 100%);
|
|
67
|
+
padding: 32px;
|
|
68
|
+
text-align: center;
|
|
69
|
+
}
|
|
70
|
+
.logo {
|
|
71
|
+
width: 200px;
|
|
72
|
+
height: 200px;
|
|
73
|
+
margin: 0 auto 12px;
|
|
74
|
+
}
|
|
75
|
+
.logo img {
|
|
76
|
+
width: 100%;
|
|
77
|
+
height: 100%;
|
|
78
|
+
object-fit: contain;
|
|
79
|
+
}
|
|
80
|
+
.header h1 {
|
|
81
|
+
color: white;
|
|
82
|
+
font-size: 24px;
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
}
|
|
85
|
+
.header p {
|
|
86
|
+
color: rgba(255, 255, 255, 0.9);
|
|
87
|
+
margin-top: 8px;
|
|
88
|
+
font-size: 14px;
|
|
89
|
+
}
|
|
90
|
+
.content {
|
|
91
|
+
padding: 32px;
|
|
92
|
+
}
|
|
93
|
+
.app-info {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
gap: 16px;
|
|
97
|
+
padding: 16px;
|
|
98
|
+
background: #f8f9fa;
|
|
99
|
+
border-radius: 12px;
|
|
100
|
+
margin-bottom: 24px;
|
|
101
|
+
}
|
|
102
|
+
.app-icon {
|
|
103
|
+
width: 48px;
|
|
104
|
+
height: 48px;
|
|
105
|
+
background: #5436da;
|
|
106
|
+
border-radius: 10px;
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
justify-content: center;
|
|
110
|
+
color: white;
|
|
111
|
+
font-weight: bold;
|
|
112
|
+
font-size: 18px;
|
|
113
|
+
}
|
|
114
|
+
.app-details h3 {
|
|
115
|
+
font-size: 16px;
|
|
116
|
+
color: #333;
|
|
117
|
+
}
|
|
118
|
+
.app-details p {
|
|
119
|
+
font-size: 13px;
|
|
120
|
+
color: #666;
|
|
121
|
+
margin-top: 2px;
|
|
122
|
+
}
|
|
123
|
+
.permissions {
|
|
124
|
+
margin-bottom: 24px;
|
|
125
|
+
}
|
|
126
|
+
.permissions h4 {
|
|
127
|
+
font-size: 14px;
|
|
128
|
+
color: #333;
|
|
129
|
+
margin-bottom: 12px;
|
|
130
|
+
}
|
|
131
|
+
.permission-item {
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
gap: 12px;
|
|
135
|
+
padding: 12px;
|
|
136
|
+
background: #f8f9fa;
|
|
137
|
+
border-radius: 8px;
|
|
138
|
+
margin-bottom: 8px;
|
|
139
|
+
}
|
|
140
|
+
.permission-icon {
|
|
141
|
+
width: 32px;
|
|
142
|
+
height: 32px;
|
|
143
|
+
background: #e8f5e9;
|
|
144
|
+
border-radius: 50%;
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
justify-content: center;
|
|
148
|
+
color: #4caf50;
|
|
149
|
+
}
|
|
150
|
+
.permission-text {
|
|
151
|
+
font-size: 14px;
|
|
152
|
+
color: #333;
|
|
153
|
+
}
|
|
154
|
+
.error-message {
|
|
155
|
+
background: #ffebee;
|
|
156
|
+
color: #c62828;
|
|
157
|
+
padding: 12px 16px;
|
|
158
|
+
border-radius: 8px;
|
|
159
|
+
margin-bottom: 16px;
|
|
160
|
+
font-size: 14px;
|
|
161
|
+
}
|
|
162
|
+
.form-group {
|
|
163
|
+
margin-bottom: 16px;
|
|
164
|
+
}
|
|
165
|
+
.form-group label {
|
|
166
|
+
display: block;
|
|
167
|
+
font-size: 14px;
|
|
168
|
+
color: #333;
|
|
169
|
+
margin-bottom: 8px;
|
|
170
|
+
font-weight: 500;
|
|
171
|
+
}
|
|
172
|
+
.form-group input {
|
|
173
|
+
width: 100%;
|
|
174
|
+
padding: 12px 16px;
|
|
175
|
+
border: 2px solid #e0e0e0;
|
|
176
|
+
border-radius: 8px;
|
|
177
|
+
font-size: 16px;
|
|
178
|
+
transition: border-color 0.2s;
|
|
179
|
+
}
|
|
180
|
+
.form-group input:focus {
|
|
181
|
+
outline: none;
|
|
182
|
+
border-color: #00b894;
|
|
183
|
+
}
|
|
184
|
+
.buttons {
|
|
185
|
+
display: flex;
|
|
186
|
+
gap: 12px;
|
|
187
|
+
margin-top: 24px;
|
|
188
|
+
}
|
|
189
|
+
.btn {
|
|
190
|
+
flex: 1;
|
|
191
|
+
padding: 14px 24px;
|
|
192
|
+
border: none;
|
|
193
|
+
border-radius: 8px;
|
|
194
|
+
font-size: 16px;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
transition: all 0.2s;
|
|
198
|
+
}
|
|
199
|
+
.btn-primary {
|
|
200
|
+
background: #00b894;
|
|
201
|
+
color: white;
|
|
202
|
+
}
|
|
203
|
+
.btn-primary:hover {
|
|
204
|
+
background: #00a383;
|
|
205
|
+
}
|
|
206
|
+
.btn-secondary {
|
|
207
|
+
background: #f5f5f5;
|
|
208
|
+
color: #666;
|
|
209
|
+
}
|
|
210
|
+
.btn-secondary:hover {
|
|
211
|
+
background: #e0e0e0;
|
|
212
|
+
}
|
|
213
|
+
.footer {
|
|
214
|
+
text-align: center;
|
|
215
|
+
padding: 16px 32px 32px;
|
|
216
|
+
font-size: 12px;
|
|
217
|
+
color: #999;
|
|
218
|
+
}
|
|
219
|
+
.footer a {
|
|
220
|
+
color: #00b894;
|
|
221
|
+
text-decoration: none;
|
|
222
|
+
}
|
|
223
|
+
</style>
|
|
224
|
+
</head>
|
|
225
|
+
<body>
|
|
226
|
+
<div class="container">
|
|
227
|
+
<div class="header">
|
|
228
|
+
<div class="logo">
|
|
229
|
+
<img src="${netpadUrl}/micro-mark-black-trans.png" alt="NetPad" onerror="this.parentElement.innerHTML='NP'">
|
|
230
|
+
</div>
|
|
231
|
+
<h1>Connect to NetPad</h1>
|
|
232
|
+
<p>Authorize Claude.ai to access your NetPad workspace</p>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="content">
|
|
235
|
+
${params.error ? `<div class="error-message">${params.error}</div>` : ''}
|
|
236
|
+
|
|
237
|
+
<div class="app-info">
|
|
238
|
+
<div class="app-icon">C</div>
|
|
239
|
+
<div class="app-details">
|
|
240
|
+
<h3>Claude.ai</h3>
|
|
241
|
+
<p>wants to access your NetPad account</p>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div class="permissions">
|
|
246
|
+
<h4>This will allow Claude.ai to:</h4>
|
|
247
|
+
${scopes.map(scope => {
|
|
248
|
+
const descriptions: Record<string, string> = {
|
|
249
|
+
'claudeai': 'Connect via Claude.ai interface',
|
|
250
|
+
'mcp': 'Use NetPad MCP tools (80+ tools)',
|
|
251
|
+
'read': 'View your forms, workflows, and data',
|
|
252
|
+
'write': 'Create and modify forms and workflows',
|
|
253
|
+
};
|
|
254
|
+
return `
|
|
255
|
+
<div class="permission-item">
|
|
256
|
+
<div class="permission-icon">✓</div>
|
|
257
|
+
<span class="permission-text">${descriptions[scope] || scope}</span>
|
|
258
|
+
</div>
|
|
259
|
+
`;
|
|
260
|
+
}).join('')}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<form method="POST" action="/api/authorize">
|
|
264
|
+
<input type="hidden" name="client_id" value="${params.clientId}">
|
|
265
|
+
<input type="hidden" name="redirect_uri" value="${params.redirectUri}">
|
|
266
|
+
<input type="hidden" name="scope" value="${params.scope}">
|
|
267
|
+
<input type="hidden" name="state" value="${params.state}">
|
|
268
|
+
<input type="hidden" name="code_challenge" value="${params.codeChallenge}">
|
|
269
|
+
<input type="hidden" name="code_challenge_method" value="${params.codeChallengeMethod}">
|
|
270
|
+
|
|
271
|
+
<div class="form-group">
|
|
272
|
+
<label for="api_key">Your NetPad API Key</label>
|
|
273
|
+
<input
|
|
274
|
+
type="password"
|
|
275
|
+
id="api_key"
|
|
276
|
+
name="api_key"
|
|
277
|
+
placeholder="np_live_xxxxx or np_test_xxxxx"
|
|
278
|
+
required
|
|
279
|
+
autocomplete="off"
|
|
280
|
+
>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<p style="font-size: 13px; color: #666; margin-bottom: 16px;">
|
|
284
|
+
Don't have an API key? <a href="${netpadUrl}/settings" target="_blank" style="color: #00b894;">Create one in NetPad Settings</a>
|
|
285
|
+
</p>
|
|
286
|
+
|
|
287
|
+
<div class="buttons">
|
|
288
|
+
<button type="button" class="btn btn-secondary" onclick="handleDeny()">Deny</button>
|
|
289
|
+
<button type="submit" class="btn btn-primary">Authorize</button>
|
|
290
|
+
</div>
|
|
291
|
+
</form>
|
|
292
|
+
</div>
|
|
293
|
+
<div class="footer">
|
|
294
|
+
By authorizing, you agree to NetPad's <a href="${netpadUrl}/terms">Terms of Service</a>
|
|
295
|
+
and <a href="${netpadUrl}/privacy">Privacy Policy</a>.
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<script>
|
|
300
|
+
function handleDeny() {
|
|
301
|
+
const redirectUri = new URL('${params.redirectUri}');
|
|
302
|
+
redirectUri.searchParams.set('error', 'access_denied');
|
|
303
|
+
redirectUri.searchParams.set('error_description', 'User denied the authorization request');
|
|
304
|
+
${params.state ? `redirectUri.searchParams.set('state', '${params.state}');` : ''}
|
|
305
|
+
window.location.href = redirectUri.toString();
|
|
306
|
+
}
|
|
307
|
+
</script>
|
|
308
|
+
</body>
|
|
309
|
+
</html>`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
313
|
+
// Handle both GET (show form) and POST (process authorization)
|
|
314
|
+
|
|
315
|
+
if (req.method === 'GET') {
|
|
316
|
+
// ========================================================================
|
|
317
|
+
// GET /authorize - Show authorization page
|
|
318
|
+
// ========================================================================
|
|
319
|
+
|
|
320
|
+
const {
|
|
321
|
+
response_type,
|
|
322
|
+
client_id,
|
|
323
|
+
redirect_uri,
|
|
324
|
+
code_challenge,
|
|
325
|
+
code_challenge_method,
|
|
326
|
+
state,
|
|
327
|
+
scope,
|
|
328
|
+
} = req.query;
|
|
329
|
+
|
|
330
|
+
// Validate required parameters
|
|
331
|
+
if (response_type !== 'code') {
|
|
332
|
+
res.status(400).json({
|
|
333
|
+
error: 'unsupported_response_type',
|
|
334
|
+
error_description: 'Only "code" response_type is supported',
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!client_id || typeof client_id !== 'string') {
|
|
340
|
+
res.status(400).json({
|
|
341
|
+
error: 'invalid_request',
|
|
342
|
+
error_description: 'client_id is required',
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
|
348
|
+
res.status(400).json({
|
|
349
|
+
error: 'invalid_request',
|
|
350
|
+
error_description: 'redirect_uri is required',
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Validate client and redirect URI
|
|
356
|
+
const clientValidation = validateClient(client_id, redirect_uri);
|
|
357
|
+
if (!clientValidation.valid) {
|
|
358
|
+
res.status(400).json({
|
|
359
|
+
error: clientValidation.error,
|
|
360
|
+
error_description: 'Invalid client_id or redirect_uri',
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// PKCE is required
|
|
366
|
+
if (!code_challenge || typeof code_challenge !== 'string') {
|
|
367
|
+
res.status(400).json({
|
|
368
|
+
error: 'invalid_request',
|
|
369
|
+
error_description: 'code_challenge is required (PKCE)',
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const challengeMethod = (code_challenge_method as string) || 'S256';
|
|
375
|
+
if (challengeMethod !== 'S256' && challengeMethod !== 'plain') {
|
|
376
|
+
res.status(400).json({
|
|
377
|
+
error: 'invalid_request',
|
|
378
|
+
error_description: 'code_challenge_method must be S256 or plain',
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Generate and serve the authorization page
|
|
384
|
+
const html = generateAuthorizationPage({
|
|
385
|
+
clientId: client_id,
|
|
386
|
+
redirectUri: redirect_uri,
|
|
387
|
+
scope: (scope as string) || 'mcp',
|
|
388
|
+
state: (state as string) || '',
|
|
389
|
+
codeChallenge: code_challenge,
|
|
390
|
+
codeChallengeMethod: challengeMethod,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
res.setHeader('Content-Type', 'text/html');
|
|
394
|
+
res.status(200).send(html);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (req.method === 'POST') {
|
|
399
|
+
// ========================================================================
|
|
400
|
+
// POST /authorize - Process authorization (user submitted the form)
|
|
401
|
+
// ========================================================================
|
|
402
|
+
|
|
403
|
+
const {
|
|
404
|
+
client_id,
|
|
405
|
+
redirect_uri,
|
|
406
|
+
scope,
|
|
407
|
+
state,
|
|
408
|
+
code_challenge,
|
|
409
|
+
code_challenge_method,
|
|
410
|
+
api_key,
|
|
411
|
+
} = req.body;
|
|
412
|
+
|
|
413
|
+
// Validate the API key against NetPad
|
|
414
|
+
if (!api_key || typeof api_key !== 'string') {
|
|
415
|
+
const html = generateAuthorizationPage({
|
|
416
|
+
clientId: client_id,
|
|
417
|
+
redirectUri: redirect_uri,
|
|
418
|
+
scope: scope || 'mcp',
|
|
419
|
+
state: state || '',
|
|
420
|
+
codeChallenge: code_challenge,
|
|
421
|
+
codeChallengeMethod: code_challenge_method || 'S256',
|
|
422
|
+
error: 'Please enter your NetPad API key',
|
|
423
|
+
});
|
|
424
|
+
res.setHeader('Content-Type', 'text/html');
|
|
425
|
+
res.status(400).send(html);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Validate API key format
|
|
430
|
+
if (!api_key.startsWith('np_live_') && !api_key.startsWith('np_test_')) {
|
|
431
|
+
const html = generateAuthorizationPage({
|
|
432
|
+
clientId: client_id,
|
|
433
|
+
redirectUri: redirect_uri,
|
|
434
|
+
scope: scope || 'mcp',
|
|
435
|
+
state: state || '',
|
|
436
|
+
codeChallenge: code_challenge,
|
|
437
|
+
codeChallengeMethod: code_challenge_method || 'S256',
|
|
438
|
+
error: 'Invalid API key format. Keys should start with np_live_ or np_test_',
|
|
439
|
+
});
|
|
440
|
+
res.setHeader('Content-Type', 'text/html');
|
|
441
|
+
res.status(400).send(html);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Validate the API key against NetPad API
|
|
446
|
+
try {
|
|
447
|
+
const netpadUrl = OAUTH_CONFIG.netpadApiUrl;
|
|
448
|
+
console.log(`[OAuth] Validating API key against ${netpadUrl}/api/v1/auth/validate`);
|
|
449
|
+
console.log(`[OAuth] Key prefix: ${api_key.substring(0, 16)}...`);
|
|
450
|
+
|
|
451
|
+
const response = await fetch(`${netpadUrl}/api/v1/auth/validate`, {
|
|
452
|
+
method: 'POST',
|
|
453
|
+
headers: {
|
|
454
|
+
'Content-Type': 'application/json',
|
|
455
|
+
'Authorization': `Bearer ${api_key}`,
|
|
456
|
+
},
|
|
457
|
+
body: JSON.stringify({ source: 'mcp-server-remote-oauth' }),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
console.log(`[OAuth] Validation response status: ${response.status}`);
|
|
461
|
+
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
const errorBody = await response.text();
|
|
464
|
+
console.log(`[OAuth] Validation error response: ${errorBody}`);
|
|
465
|
+
|
|
466
|
+
let errorMessage = 'Invalid or expired API key. Please check your key and try again.';
|
|
467
|
+
try {
|
|
468
|
+
const errorJson = JSON.parse(errorBody);
|
|
469
|
+
if (errorJson.error?.message) {
|
|
470
|
+
errorMessage = errorJson.error.message;
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
// Keep default error message
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const html = generateAuthorizationPage({
|
|
477
|
+
clientId: client_id,
|
|
478
|
+
redirectUri: redirect_uri,
|
|
479
|
+
scope: scope || 'mcp',
|
|
480
|
+
state: state || '',
|
|
481
|
+
codeChallenge: code_challenge,
|
|
482
|
+
codeChallengeMethod: code_challenge_method || 'S256',
|
|
483
|
+
error: errorMessage,
|
|
484
|
+
});
|
|
485
|
+
res.setHeader('Content-Type', 'text/html');
|
|
486
|
+
res.status(400).send(html);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const userData = await response.json();
|
|
491
|
+
const userId = userData.userId || userData.user?.id || 'unknown';
|
|
492
|
+
const organizationId = userData.organizationId || userData.organization?.id || 'unknown';
|
|
493
|
+
|
|
494
|
+
// Validate scopes
|
|
495
|
+
const validatedScopes = validateScopes(scope || 'mcp', client_id);
|
|
496
|
+
|
|
497
|
+
// Generate authorization code
|
|
498
|
+
const authCode = generateAuthorizationCode({
|
|
499
|
+
clientId: client_id,
|
|
500
|
+
redirectUri: redirect_uri,
|
|
501
|
+
scope: validatedScopes.join(' '),
|
|
502
|
+
codeChallenge: code_challenge,
|
|
503
|
+
codeChallengeMethod: code_challenge_method || 'S256',
|
|
504
|
+
userId,
|
|
505
|
+
organizationId,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// Build redirect URL with authorization code
|
|
509
|
+
const redirectUrl = new URL(redirect_uri);
|
|
510
|
+
redirectUrl.searchParams.set('code', authCode);
|
|
511
|
+
if (state) {
|
|
512
|
+
redirectUrl.searchParams.set('state', state);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Redirect back to Claude.ai
|
|
516
|
+
res.redirect(302, redirectUrl.toString());
|
|
517
|
+
return;
|
|
518
|
+
} catch (error) {
|
|
519
|
+
console.error('Error validating API key:', error);
|
|
520
|
+
|
|
521
|
+
const html = generateAuthorizationPage({
|
|
522
|
+
clientId: client_id,
|
|
523
|
+
redirectUri: redirect_uri,
|
|
524
|
+
scope: scope || 'mcp',
|
|
525
|
+
state: state || '',
|
|
526
|
+
codeChallenge: code_challenge,
|
|
527
|
+
codeChallengeMethod: code_challenge_method || 'S256',
|
|
528
|
+
error: 'Unable to validate API key. Please try again.',
|
|
529
|
+
});
|
|
530
|
+
res.setHeader('Content-Type', 'text/html');
|
|
531
|
+
res.status(500).send(html);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Method not allowed
|
|
537
|
+
res.status(405).json({ error: 'Method not allowed' });
|
|
538
|
+
}
|
package/api/index.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
2
2
|
|
|
3
3
|
export default function handler(req: VercelRequest, res: VercelResponse) {
|
|
4
|
+
const baseUrl = process.env.OAUTH_ISSUER || 'https://mcp.netpad.io';
|
|
5
|
+
|
|
4
6
|
res.status(200).json({
|
|
5
7
|
name: '@netpad/mcp-server-remote',
|
|
6
|
-
version: '1.
|
|
8
|
+
version: '1.2.0',
|
|
7
9
|
description: 'NetPad MCP Server - Remote API for Claude Custom Connectors. Includes all 80+ tools from @netpad/mcp-server.',
|
|
8
10
|
endpoints: {
|
|
9
11
|
mcp: '/mcp',
|
|
10
12
|
health: '/health',
|
|
13
|
+
authorize: '/authorize',
|
|
14
|
+
token: '/token',
|
|
15
|
+
oauth_metadata: '/.well-known/oauth-authorization-server',
|
|
11
16
|
},
|
|
12
17
|
features: {
|
|
13
18
|
tools: '80+ tools',
|
|
@@ -25,9 +30,27 @@ export default function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
25
30
|
],
|
|
26
31
|
},
|
|
27
32
|
authentication: {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
methods: ['oauth2', 'api_key'],
|
|
34
|
+
oauth2: {
|
|
35
|
+
type: 'OAuth 2.0 + PKCE',
|
|
36
|
+
authorization_endpoint: `${baseUrl}/authorize`,
|
|
37
|
+
token_endpoint: `${baseUrl}/token`,
|
|
38
|
+
metadata_endpoint: `${baseUrl}/.well-known/oauth-authorization-server`,
|
|
39
|
+
},
|
|
40
|
+
api_key: {
|
|
41
|
+
type: 'Bearer token',
|
|
42
|
+
format: 'Authorization: Bearer np_live_xxx',
|
|
43
|
+
generateAt: 'https://netpad.io/settings',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
claude_ai_setup: {
|
|
47
|
+
instructions: [
|
|
48
|
+
'1. Go to Claude.ai Settings > Connectors',
|
|
49
|
+
'2. Click "Add custom connector"',
|
|
50
|
+
`3. Enter URL: ${baseUrl}/mcp`,
|
|
51
|
+
'4. Click "Connect" to authorize with your NetPad account',
|
|
52
|
+
'5. Enable the connector in your conversations',
|
|
53
|
+
],
|
|
31
54
|
},
|
|
32
55
|
documentation: 'https://docs.netpad.io/docs/developer/mcp-server',
|
|
33
56
|
});
|