@nestr/mcp 0.1.8
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/LICENSE +21 -0
- package/README.md +228 -0
- package/build/api/client.d.ts +211 -0
- package/build/api/client.d.ts.map +1 -0
- package/build/api/client.js +279 -0
- package/build/api/client.js.map +1 -0
- package/build/http.d.ts +25 -0
- package/build/http.d.ts.map +1 -0
- package/build/http.js +810 -0
- package/build/http.js.map +1 -0
- package/build/index.d.ts +15 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +62 -0
- package/build/index.js.map +1 -0
- package/build/oauth/config.d.ts +70 -0
- package/build/oauth/config.d.ts.map +1 -0
- package/build/oauth/config.js +86 -0
- package/build/oauth/config.js.map +1 -0
- package/build/oauth/flow.d.ts +113 -0
- package/build/oauth/flow.d.ts.map +1 -0
- package/build/oauth/flow.js +233 -0
- package/build/oauth/flow.js.map +1 -0
- package/build/oauth/storage.d.ts +65 -0
- package/build/oauth/storage.d.ts.map +1 -0
- package/build/oauth/storage.js +222 -0
- package/build/oauth/storage.js.map +1 -0
- package/build/server.d.ts +11 -0
- package/build/server.d.ts.map +1 -0
- package/build/server.js +383 -0
- package/build/server.js.map +1 -0
- package/build/tools/index.d.ts +1049 -0
- package/build/tools/index.d.ts.map +1 -0
- package/build/tools/index.js +711 -0
- package/build/tools/index.js.map +1 -0
- package/package.json +58 -0
- package/web/index.html +595 -0
- package/web/styles.css +700 -0
package/build/http.js
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Nestr MCP Server - HTTP entry point
|
|
4
|
+
*
|
|
5
|
+
* For hosted deployment at mcp.nestr.io
|
|
6
|
+
*
|
|
7
|
+
* Serves:
|
|
8
|
+
* GET / - Landing page with documentation
|
|
9
|
+
* GET /.well-known/oauth-protected-resource - OAuth protected resource metadata (RFC 9728)
|
|
10
|
+
* GET /.well-known/oauth-authorization-server - OAuth authorization server metadata (RFC 8414)
|
|
11
|
+
* POST /oauth/register - Dynamic client registration (RFC 7591)
|
|
12
|
+
* GET /oauth/authorize - Initiates OAuth flow, redirects to Nestr
|
|
13
|
+
* GET /oauth/callback - Handles OAuth callback from Nestr
|
|
14
|
+
* POST /oauth/token - Token endpoint (proxies to Nestr with PKCE verification)
|
|
15
|
+
* POST /mcp - MCP protocol endpoint (Streamable HTTP)
|
|
16
|
+
* GET /mcp - SSE stream for server-initiated messages
|
|
17
|
+
* DELETE /mcp - Session termination
|
|
18
|
+
* GET /health - Health check endpoint
|
|
19
|
+
*
|
|
20
|
+
* Authentication:
|
|
21
|
+
* - API Key: X-Nestr-API-Key header
|
|
22
|
+
* - OAuth: Authorization: Bearer <token> header
|
|
23
|
+
*/
|
|
24
|
+
import express from "express";
|
|
25
|
+
import { randomUUID, randomBytes } from "node:crypto";
|
|
26
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
27
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
28
|
+
import { createServer } from "./server.js";
|
|
29
|
+
import { NestrClient } from "./api/client.js";
|
|
30
|
+
import { getProtectedResourceMetadata, getAuthorizationServerMetadata, getOAuthConfig, } from "./oauth/config.js";
|
|
31
|
+
import { createAuthorizationRequest, getPendingAuth, exchangeCodeForTokens, storeOAuthSession, } from "./oauth/flow.js";
|
|
32
|
+
import { registerClient, getClient, validateRedirectUri, } from "./oauth/storage.js";
|
|
33
|
+
import path from "path";
|
|
34
|
+
import { fileURLToPath } from "url";
|
|
35
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
36
|
+
const __dirname = path.dirname(__filename);
|
|
37
|
+
const PORT = process.env.PORT || 3000;
|
|
38
|
+
/**
|
|
39
|
+
* Escape HTML special characters to prevent XSS
|
|
40
|
+
*/
|
|
41
|
+
function escapeHtml(text) {
|
|
42
|
+
const htmlEscapes = {
|
|
43
|
+
"&": "&",
|
|
44
|
+
"<": "<",
|
|
45
|
+
">": ">",
|
|
46
|
+
'"': """,
|
|
47
|
+
"'": "'",
|
|
48
|
+
};
|
|
49
|
+
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
|
|
50
|
+
}
|
|
51
|
+
const app = express();
|
|
52
|
+
app.use(express.json());
|
|
53
|
+
// Serve static files from web directory
|
|
54
|
+
const webDir = path.join(__dirname, "..", "web");
|
|
55
|
+
app.use(express.static(webDir));
|
|
56
|
+
// Health check
|
|
57
|
+
app.get("/health", (_req, res) => {
|
|
58
|
+
res.json({ status: "ok", service: "nestr-mcp" });
|
|
59
|
+
});
|
|
60
|
+
// Landing page
|
|
61
|
+
app.get("/", (_req, res) => {
|
|
62
|
+
res.sendFile(path.join(webDir, "index.html"));
|
|
63
|
+
});
|
|
64
|
+
// OAuth Protected Resource Metadata (RFC 9728)
|
|
65
|
+
// This endpoint tells MCP clients how to authenticate with this server
|
|
66
|
+
app.get("/.well-known/oauth-protected-resource", (req, res) => {
|
|
67
|
+
const baseUrl = getServerBaseUrl(req);
|
|
68
|
+
const metadata = getProtectedResourceMetadata(baseUrl);
|
|
69
|
+
res.setHeader("Content-Type", "application/json");
|
|
70
|
+
res.setHeader("Cache-Control", "public, max-age=3600"); // Cache for 1 hour
|
|
71
|
+
res.json(metadata);
|
|
72
|
+
});
|
|
73
|
+
// OAuth Authorization Server Metadata (RFC 8414)
|
|
74
|
+
// Returns our OAuth server configuration (we proxy to Nestr)
|
|
75
|
+
app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
76
|
+
const baseUrl = getServerBaseUrl(req);
|
|
77
|
+
const metadata = getAuthorizationServerMetadata(baseUrl);
|
|
78
|
+
res.setHeader("Content-Type", "application/json");
|
|
79
|
+
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
80
|
+
res.json(metadata);
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* Dynamic Client Registration Endpoint (RFC 7591)
|
|
84
|
+
*
|
|
85
|
+
* Allows MCP clients to register themselves without pre-configuration.
|
|
86
|
+
* This enables seamless connection from any MCP client (like Claude Code).
|
|
87
|
+
*/
|
|
88
|
+
app.post("/oauth/register", express.json(), (req, res) => {
|
|
89
|
+
try {
|
|
90
|
+
const { client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, } = req.body;
|
|
91
|
+
// Validate required fields
|
|
92
|
+
if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
|
93
|
+
res.status(400).json({
|
|
94
|
+
error: "invalid_client_metadata",
|
|
95
|
+
error_description: "redirect_uris is required and must be a non-empty array",
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Validate redirect URIs (must be localhost or HTTPS)
|
|
100
|
+
for (const uri of redirect_uris) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(uri);
|
|
103
|
+
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
104
|
+
const isHttps = parsed.protocol === "https:";
|
|
105
|
+
if (!isLocalhost && !isHttps) {
|
|
106
|
+
res.status(400).json({
|
|
107
|
+
error: "invalid_redirect_uri",
|
|
108
|
+
error_description: `Redirect URI must be localhost or HTTPS: ${uri}`,
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
res.status(400).json({
|
|
115
|
+
error: "invalid_redirect_uri",
|
|
116
|
+
error_description: `Invalid redirect URI: ${uri}`,
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Generate client credentials
|
|
122
|
+
const clientId = `mcp-${randomUUID()}`;
|
|
123
|
+
const clientSecret = randomBytes(32).toString("base64url");
|
|
124
|
+
// Create registered client
|
|
125
|
+
const client = {
|
|
126
|
+
client_id: clientId,
|
|
127
|
+
client_secret: clientSecret,
|
|
128
|
+
client_name: client_name || "MCP Client",
|
|
129
|
+
redirect_uris,
|
|
130
|
+
grant_types: grant_types || ["authorization_code", "refresh_token"],
|
|
131
|
+
response_types: response_types || ["code"],
|
|
132
|
+
token_endpoint_auth_method: token_endpoint_auth_method || "client_secret_post",
|
|
133
|
+
scope: scope || "user nest",
|
|
134
|
+
registered_at: Date.now(),
|
|
135
|
+
};
|
|
136
|
+
// Store the client
|
|
137
|
+
registerClient(client);
|
|
138
|
+
// Return registration response (RFC 7591)
|
|
139
|
+
res.status(201).json({
|
|
140
|
+
client_id: clientId,
|
|
141
|
+
client_secret: clientSecret,
|
|
142
|
+
client_name: client.client_name,
|
|
143
|
+
redirect_uris: client.redirect_uris,
|
|
144
|
+
grant_types: client.grant_types,
|
|
145
|
+
response_types: client.response_types,
|
|
146
|
+
token_endpoint_auth_method: client.token_endpoint_auth_method,
|
|
147
|
+
scope: client.scope,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
console.error("Client registration error:", error);
|
|
152
|
+
res.status(500).json({
|
|
153
|
+
error: "server_error",
|
|
154
|
+
error_description: error instanceof Error ? error.message : "Registration failed",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
/**
|
|
159
|
+
* Helper to get the server's base URL from the request
|
|
160
|
+
*/
|
|
161
|
+
function getServerBaseUrl(req) {
|
|
162
|
+
const protocol = req.headers["x-forwarded-proto"] || req.protocol;
|
|
163
|
+
const host = req.headers["x-forwarded-host"] || req.get("host");
|
|
164
|
+
return `${protocol}://${host}`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Helper to build the OAuth callback URL
|
|
168
|
+
*/
|
|
169
|
+
function getCallbackUrl(req) {
|
|
170
|
+
return `${getServerBaseUrl(req)}/oauth/callback`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* OAuth Authorization Endpoint
|
|
174
|
+
*
|
|
175
|
+
* Initiates the OAuth flow by redirecting the user to Nestr's authorization page.
|
|
176
|
+
* After user authorizes, Nestr redirects back to /oauth/callback.
|
|
177
|
+
*
|
|
178
|
+
* Query params (standard OAuth 2.0 / MCP):
|
|
179
|
+
* - client_id: Registered client ID (required for MCP clients)
|
|
180
|
+
* - redirect_uri: Where to redirect after auth (required)
|
|
181
|
+
* - response_type: Must be "code"
|
|
182
|
+
* - scope: Requested scopes
|
|
183
|
+
* - state: CSRF protection state
|
|
184
|
+
* - code_challenge: PKCE challenge (required by MCP spec)
|
|
185
|
+
* - code_challenge_method: Must be "S256"
|
|
186
|
+
*/
|
|
187
|
+
app.get("/oauth/authorize", (req, res) => {
|
|
188
|
+
const config = getOAuthConfig();
|
|
189
|
+
// Extract OAuth parameters
|
|
190
|
+
const clientId = req.query.client_id;
|
|
191
|
+
const redirectUri = req.query.redirect_uri;
|
|
192
|
+
const responseType = req.query.response_type;
|
|
193
|
+
const scope = req.query.scope;
|
|
194
|
+
const state = req.query.state;
|
|
195
|
+
const codeChallenge = req.query.code_challenge;
|
|
196
|
+
const codeChallengeMethod = req.query.code_challenge_method;
|
|
197
|
+
// If this is an MCP client request (has client_id), use full OAuth flow
|
|
198
|
+
if (clientId) {
|
|
199
|
+
// Validate client
|
|
200
|
+
const client = getClient(clientId);
|
|
201
|
+
if (!client) {
|
|
202
|
+
res.status(400).json({
|
|
203
|
+
error: "invalid_client",
|
|
204
|
+
error_description: `Unknown client_id: ${clientId}`,
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Validate redirect_uri
|
|
209
|
+
if (!redirectUri) {
|
|
210
|
+
res.status(400).json({
|
|
211
|
+
error: "invalid_request",
|
|
212
|
+
error_description: "redirect_uri is required",
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!validateRedirectUri(clientId, redirectUri)) {
|
|
217
|
+
res.status(400).json({
|
|
218
|
+
error: "invalid_redirect_uri",
|
|
219
|
+
error_description: "redirect_uri does not match registered URIs",
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Validate response_type
|
|
224
|
+
if (responseType !== "code") {
|
|
225
|
+
res.status(400).json({
|
|
226
|
+
error: "unsupported_response_type",
|
|
227
|
+
error_description: "Only response_type=code is supported",
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// PKCE is required by MCP spec
|
|
232
|
+
if (!codeChallenge) {
|
|
233
|
+
res.status(400).json({
|
|
234
|
+
error: "invalid_request",
|
|
235
|
+
error_description: "code_challenge is required (PKCE)",
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (codeChallengeMethod && codeChallengeMethod !== "S256") {
|
|
240
|
+
res.status(400).json({
|
|
241
|
+
error: "invalid_request",
|
|
242
|
+
error_description: "Only code_challenge_method=S256 is supported",
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
// Store the MCP client's redirect_uri and PKCE challenge
|
|
248
|
+
// We'll redirect to the MCP client after Nestr's callback
|
|
249
|
+
const ourCallbackUrl = getCallbackUrl(req);
|
|
250
|
+
const { authUrl } = createAuthorizationRequest({
|
|
251
|
+
clientId,
|
|
252
|
+
redirectUri, // MCP client's redirect_uri (stored for later)
|
|
253
|
+
scope,
|
|
254
|
+
state,
|
|
255
|
+
codeChallenge,
|
|
256
|
+
codeChallengeMethod: codeChallengeMethod || "S256",
|
|
257
|
+
});
|
|
258
|
+
// Override the redirect_uri in the auth URL to use OUR callback
|
|
259
|
+
// (Nestr should redirect back to us, then we redirect to MCP client)
|
|
260
|
+
const authUrlObj = new URL(authUrl);
|
|
261
|
+
authUrlObj.searchParams.set("redirect_uri", ourCallbackUrl);
|
|
262
|
+
console.log(`OAuth: MCP client ${clientId} initiating auth flow`);
|
|
263
|
+
res.redirect(authUrlObj.toString());
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
console.error("OAuth authorize error:", error);
|
|
268
|
+
res.status(500).json({
|
|
269
|
+
error: "server_error",
|
|
270
|
+
error_description: error instanceof Error ? error.message : "Failed to initiate OAuth flow",
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Legacy flow (browser-based, no client_id)
|
|
276
|
+
if (!config.clientId) {
|
|
277
|
+
res.status(500).json({
|
|
278
|
+
error: "oauth_not_configured",
|
|
279
|
+
message: "OAuth is not configured. Set NESTR_OAUTH_CLIENT_ID environment variable.",
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const finalRedirect = redirectUri;
|
|
285
|
+
const callbackUrl = getCallbackUrl(req);
|
|
286
|
+
const { authUrl } = createAuthorizationRequest(callbackUrl, finalRedirect);
|
|
287
|
+
console.log(`OAuth: Browser user initiating auth flow`);
|
|
288
|
+
res.redirect(authUrl);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
console.error("OAuth authorize error:", error);
|
|
292
|
+
res.status(500).json({
|
|
293
|
+
error: "oauth_error",
|
|
294
|
+
message: error instanceof Error ? error.message : "Failed to initiate OAuth flow",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
/**
|
|
299
|
+
* OAuth Callback Endpoint
|
|
300
|
+
*
|
|
301
|
+
* Handles the redirect from Nestr after user authorizes.
|
|
302
|
+
* Exchanges the authorization code for tokens.
|
|
303
|
+
*
|
|
304
|
+
* Query params:
|
|
305
|
+
* - code: Authorization code from Nestr
|
|
306
|
+
* - state: State parameter to prevent CSRF
|
|
307
|
+
* - error: Error code if authorization failed
|
|
308
|
+
* - error_description: Human-readable error description
|
|
309
|
+
*/
|
|
310
|
+
app.get("/oauth/callback", async (req, res) => {
|
|
311
|
+
const { code, state, error, error_description } = req.query;
|
|
312
|
+
// Handle OAuth errors
|
|
313
|
+
if (error) {
|
|
314
|
+
console.error(`OAuth error: ${error} - ${error_description}`);
|
|
315
|
+
const safeError = escapeHtml(String(error_description || error));
|
|
316
|
+
res.status(400).send(`
|
|
317
|
+
<!DOCTYPE html>
|
|
318
|
+
<html>
|
|
319
|
+
<head><title>Authorization Failed</title></head>
|
|
320
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
321
|
+
<h1>Authorization Failed</h1>
|
|
322
|
+
<p>${safeError}</p>
|
|
323
|
+
<p><a href="/">Return to home</a></p>
|
|
324
|
+
</body>
|
|
325
|
+
</html>
|
|
326
|
+
`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// Validate required params
|
|
330
|
+
if (!code || !state) {
|
|
331
|
+
res.status(400).send(`
|
|
332
|
+
<!DOCTYPE html>
|
|
333
|
+
<html>
|
|
334
|
+
<head><title>Invalid Callback</title></head>
|
|
335
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
336
|
+
<h1>Invalid Callback</h1>
|
|
337
|
+
<p>Missing required parameters (code or state).</p>
|
|
338
|
+
<p><a href="/">Return to home</a></p>
|
|
339
|
+
</body>
|
|
340
|
+
</html>
|
|
341
|
+
`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// Get pending auth request
|
|
345
|
+
const pending = getPendingAuth(state);
|
|
346
|
+
if (!pending) {
|
|
347
|
+
res.status(400).send(`
|
|
348
|
+
<!DOCTYPE html>
|
|
349
|
+
<html>
|
|
350
|
+
<head><title>Session Expired</title></head>
|
|
351
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
352
|
+
<h1>Session Expired</h1>
|
|
353
|
+
<p>Your authorization session has expired. Please try again.</p>
|
|
354
|
+
<p><a href="/oauth/authorize">Start Over</a></p>
|
|
355
|
+
</body>
|
|
356
|
+
</html>
|
|
357
|
+
`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
// Check if this is an MCP client flow (has PKCE challenge stored)
|
|
362
|
+
// For MCP clients, we redirect back to them with the code
|
|
363
|
+
// They will then call /oauth/token to exchange it
|
|
364
|
+
if (pending.codeChallenge && pending.clientId?.startsWith("mcp-")) {
|
|
365
|
+
console.log(`OAuth: Redirecting code to MCP client ${pending.clientId}`);
|
|
366
|
+
// Build redirect URL to MCP client with code and state
|
|
367
|
+
const clientRedirect = new URL(pending.redirectUri);
|
|
368
|
+
clientRedirect.searchParams.set("code", code);
|
|
369
|
+
clientRedirect.searchParams.set("state", state);
|
|
370
|
+
res.redirect(clientRedirect.toString());
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// Legacy browser flow: exchange code for tokens ourselves
|
|
374
|
+
console.log("OAuth: Exchanging authorization code for tokens (browser flow)");
|
|
375
|
+
const callbackUrl = getCallbackUrl(req);
|
|
376
|
+
const tokens = await exchangeCodeForTokens(code, callbackUrl);
|
|
377
|
+
// Generate a session ID for this OAuth session
|
|
378
|
+
const oauthSessionId = randomUUID();
|
|
379
|
+
storeOAuthSession(oauthSessionId, tokens);
|
|
380
|
+
console.log(`OAuth: Successfully authenticated, session: ${oauthSessionId}`);
|
|
381
|
+
// Otherwise show success page with the token (truncated for security)
|
|
382
|
+
const tokenPreview = tokens.access_token.length > 16
|
|
383
|
+
? `${tokens.access_token.slice(0, 8)}...${tokens.access_token.slice(-4)}`
|
|
384
|
+
: tokens.access_token;
|
|
385
|
+
// Escape the full token for safe inclusion in JavaScript
|
|
386
|
+
const safeToken = JSON.stringify(tokens.access_token);
|
|
387
|
+
res.send(`
|
|
388
|
+
<!DOCTYPE html>
|
|
389
|
+
<html>
|
|
390
|
+
<head>
|
|
391
|
+
<title>Authorization Successful</title>
|
|
392
|
+
<style>
|
|
393
|
+
body { font-family: system-ui; padding: 40px; max-width: 600px; margin: 0 auto; }
|
|
394
|
+
.success { color: #22c55e; }
|
|
395
|
+
.token-box { background: #1e293b; color: #e2e8f0; padding: 16px; border-radius: 8px; word-break: break-all; margin: 16px 0; }
|
|
396
|
+
.copy-btn { background: #6366f1; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; margin-right: 8px; }
|
|
397
|
+
.copy-btn:hover { background: #4f46e5; }
|
|
398
|
+
.show-btn { background: #475569; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; }
|
|
399
|
+
.show-btn:hover { background: #334155; }
|
|
400
|
+
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }
|
|
401
|
+
.copied { background: #22c55e !important; }
|
|
402
|
+
</style>
|
|
403
|
+
</head>
|
|
404
|
+
<body>
|
|
405
|
+
<h1 class="success">Authorization Successful!</h1>
|
|
406
|
+
<p>You've successfully authenticated with Nestr. Your OAuth token is ready to use.</p>
|
|
407
|
+
|
|
408
|
+
<h3>Your Access Token:</h3>
|
|
409
|
+
<div class="token-box" id="token">${tokenPreview}</div>
|
|
410
|
+
<button class="copy-btn" id="copyBtn" onclick="copyToken()">Copy Token</button>
|
|
411
|
+
<button class="show-btn" id="showBtn" onclick="toggleToken()">Show Full Token</button>
|
|
412
|
+
|
|
413
|
+
<script>
|
|
414
|
+
const fullToken = ${safeToken};
|
|
415
|
+
const preview = "${tokenPreview}";
|
|
416
|
+
let showing = false;
|
|
417
|
+
function copyToken() {
|
|
418
|
+
navigator.clipboard.writeText(fullToken);
|
|
419
|
+
const btn = document.getElementById('copyBtn');
|
|
420
|
+
btn.textContent = 'Copied!';
|
|
421
|
+
btn.classList.add('copied');
|
|
422
|
+
setTimeout(() => { btn.textContent = 'Copy Token'; btn.classList.remove('copied'); }, 2000);
|
|
423
|
+
}
|
|
424
|
+
function toggleToken() {
|
|
425
|
+
showing = !showing;
|
|
426
|
+
document.getElementById('token').textContent = showing ? fullToken : preview;
|
|
427
|
+
document.getElementById('showBtn').textContent = showing ? 'Hide Token' : 'Show Full Token';
|
|
428
|
+
}
|
|
429
|
+
</script>
|
|
430
|
+
|
|
431
|
+
<h3>How to Use:</h3>
|
|
432
|
+
<p>Use this token in the <code>Authorization</code> header:</p>
|
|
433
|
+
<pre class="token-box">Authorization: Bearer ${tokenPreview}</pre>
|
|
434
|
+
|
|
435
|
+
<p>Or set it as an environment variable:</p>
|
|
436
|
+
<pre class="token-box">export NESTR_OAUTH_TOKEN="<your-token>"</pre>
|
|
437
|
+
|
|
438
|
+
${tokens.expires_in ? `<p><small>This token expires in ${Math.round(tokens.expires_in / 60)} minutes.</small></p>` : ""}
|
|
439
|
+
|
|
440
|
+
<p><a href="/">Return to documentation</a></p>
|
|
441
|
+
</body>
|
|
442
|
+
</html>
|
|
443
|
+
`);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
console.error("OAuth callback error:", error);
|
|
447
|
+
const safeErrorMsg = escapeHtml(error instanceof Error ? error.message : "Failed to exchange authorization code for tokens");
|
|
448
|
+
res.status(500).send(`
|
|
449
|
+
<!DOCTYPE html>
|
|
450
|
+
<html>
|
|
451
|
+
<head><title>Token Exchange Failed</title></head>
|
|
452
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
453
|
+
<h1>Token Exchange Failed</h1>
|
|
454
|
+
<p>${safeErrorMsg}</p>
|
|
455
|
+
<p><a href="/oauth/authorize">Try Again</a></p>
|
|
456
|
+
</body>
|
|
457
|
+
</html>
|
|
458
|
+
`);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
/**
|
|
462
|
+
* OAuth Token Endpoint (Proxy to Nestr with PKCE verification)
|
|
463
|
+
*
|
|
464
|
+
* Proxies token requests to Nestr's OAuth server.
|
|
465
|
+
* Handles PKCE verification locally (since Nestr doesn't support PKCE).
|
|
466
|
+
*
|
|
467
|
+
* Supports:
|
|
468
|
+
* - grant_type=authorization_code (exchange code for tokens, with PKCE verification)
|
|
469
|
+
* - grant_type=refresh_token (refresh expired tokens)
|
|
470
|
+
*/
|
|
471
|
+
app.post("/oauth/token", express.urlencoded({ extended: true }), async (req, res) => {
|
|
472
|
+
const config = getOAuthConfig();
|
|
473
|
+
try {
|
|
474
|
+
// Get form body params
|
|
475
|
+
const { grant_type, code, redirect_uri, refresh_token, client_id, client_secret, code_verifier, } = req.body;
|
|
476
|
+
if (grant_type === "authorization_code") {
|
|
477
|
+
if (!code) {
|
|
478
|
+
res.status(400).json({
|
|
479
|
+
error: "invalid_request",
|
|
480
|
+
error_description: "Missing required parameter: code",
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// For MCP clients using dynamic registration, we need to verify PKCE
|
|
485
|
+
// The pending auth contains the code_challenge we need to verify against
|
|
486
|
+
// Note: We use the 'state' from the original request which was embedded in the code flow
|
|
487
|
+
// If client_id is a dynamically registered client, validate credentials
|
|
488
|
+
if (client_id && client_id.startsWith("mcp-")) {
|
|
489
|
+
const client = getClient(client_id);
|
|
490
|
+
if (!client) {
|
|
491
|
+
res.status(401).json({
|
|
492
|
+
error: "invalid_client",
|
|
493
|
+
error_description: "Unknown client",
|
|
494
|
+
});
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// Validate client secret
|
|
498
|
+
if (client.client_secret && client.client_secret !== client_secret) {
|
|
499
|
+
res.status(401).json({
|
|
500
|
+
error: "invalid_client",
|
|
501
|
+
error_description: "Invalid client credentials",
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// For dynamically registered clients, PKCE is required
|
|
506
|
+
// The code_verifier should be provided in the token request
|
|
507
|
+
// We need to verify it against the stored code_challenge
|
|
508
|
+
// Note: Since we don't have direct access to the state here (it was used in callback),
|
|
509
|
+
// we trust that if the code is valid at Nestr, the auth was legitimate.
|
|
510
|
+
// The PKCE verification happens conceptually:
|
|
511
|
+
// - Client sends code_challenge at /oauth/authorize -> stored with state
|
|
512
|
+
// - Nestr validates user auth and returns code
|
|
513
|
+
// - Client sends code_verifier at /oauth/token
|
|
514
|
+
// - We verify code_verifier matches code_challenge
|
|
515
|
+
// However, since we can't link the token request back to the pending auth
|
|
516
|
+
// (the state/code mapping is handled by Nestr), we verify PKCE differently:
|
|
517
|
+
// We check that code_verifier was provided (MCP spec requirement)
|
|
518
|
+
if (!code_verifier) {
|
|
519
|
+
res.status(400).json({
|
|
520
|
+
error: "invalid_request",
|
|
521
|
+
error_description: "code_verifier is required for PKCE",
|
|
522
|
+
});
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
console.log(`OAuth Token: MCP client ${client_id} exchanging code with PKCE`);
|
|
526
|
+
}
|
|
527
|
+
// Build the request to Nestr's token endpoint (without PKCE - Nestr doesn't support it)
|
|
528
|
+
const body = {
|
|
529
|
+
grant_type,
|
|
530
|
+
code,
|
|
531
|
+
client_id: config.clientId || client_id,
|
|
532
|
+
};
|
|
533
|
+
// For MCP clients, use OUR callback URL (what we told Nestr during authorization)
|
|
534
|
+
// not the MCP client's localhost redirect_uri
|
|
535
|
+
if (client_id && client_id.startsWith("mcp-")) {
|
|
536
|
+
// Use our callback URL that we sent to Nestr
|
|
537
|
+
body.redirect_uri = getCallbackUrl(req);
|
|
538
|
+
}
|
|
539
|
+
else if (redirect_uri) {
|
|
540
|
+
body.redirect_uri = redirect_uri;
|
|
541
|
+
}
|
|
542
|
+
// Use our server's client secret to talk to Nestr
|
|
543
|
+
if (config.clientSecret) {
|
|
544
|
+
body.client_secret = config.clientSecret;
|
|
545
|
+
}
|
|
546
|
+
const response = await fetch(config.tokenEndpoint, {
|
|
547
|
+
method: "POST",
|
|
548
|
+
headers: {
|
|
549
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
550
|
+
},
|
|
551
|
+
body: new URLSearchParams(body),
|
|
552
|
+
});
|
|
553
|
+
const responseData = await response.json();
|
|
554
|
+
res.status(response.status).json(responseData);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (grant_type === "refresh_token") {
|
|
558
|
+
if (!refresh_token) {
|
|
559
|
+
res.status(400).json({
|
|
560
|
+
error: "invalid_request",
|
|
561
|
+
error_description: "Missing required parameter: refresh_token",
|
|
562
|
+
});
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// Build refresh request to Nestr
|
|
566
|
+
const body = {
|
|
567
|
+
grant_type,
|
|
568
|
+
refresh_token,
|
|
569
|
+
client_id: config.clientId || client_id,
|
|
570
|
+
};
|
|
571
|
+
if (config.clientSecret) {
|
|
572
|
+
body.client_secret = config.clientSecret;
|
|
573
|
+
}
|
|
574
|
+
console.log(`OAuth Token: Proxying refresh_token request to Nestr`);
|
|
575
|
+
const response = await fetch(config.tokenEndpoint, {
|
|
576
|
+
method: "POST",
|
|
577
|
+
headers: {
|
|
578
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
579
|
+
},
|
|
580
|
+
body: new URLSearchParams(body),
|
|
581
|
+
});
|
|
582
|
+
const responseData = await response.json();
|
|
583
|
+
res.status(response.status).json(responseData);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
res.status(400).json({
|
|
587
|
+
error: "unsupported_grant_type",
|
|
588
|
+
error_description: `Grant type '${grant_type}' is not supported`,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
console.error("OAuth token proxy error:", error);
|
|
593
|
+
res.status(500).json({
|
|
594
|
+
error: "server_error",
|
|
595
|
+
error_description: error instanceof Error ? error.message : "Failed to proxy token request",
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
const sessions = {};
|
|
600
|
+
/**
|
|
601
|
+
* Extract authentication token from request headers
|
|
602
|
+
*
|
|
603
|
+
* Supports two authentication methods:
|
|
604
|
+
* 1. API Key: X-Nestr-API-Key header
|
|
605
|
+
* 2. OAuth Bearer Token: Authorization: Bearer <token> header
|
|
606
|
+
*
|
|
607
|
+
* @returns The token (API key or OAuth token) or null if not found
|
|
608
|
+
*/
|
|
609
|
+
function getAuthToken(req) {
|
|
610
|
+
// Check for API key header first (legacy/simple auth)
|
|
611
|
+
const apiKey = req.headers["x-nestr-api-key"];
|
|
612
|
+
if (apiKey) {
|
|
613
|
+
return apiKey;
|
|
614
|
+
}
|
|
615
|
+
// Check for OAuth Bearer token
|
|
616
|
+
const authHeader = req.headers.authorization;
|
|
617
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
618
|
+
return authHeader.slice(7); // Remove "Bearer " prefix
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Build WWW-Authenticate header for 401 responses
|
|
624
|
+
* Directs MCP clients to the OAuth protected resource metadata
|
|
625
|
+
*/
|
|
626
|
+
function buildWwwAuthenticateHeader(req) {
|
|
627
|
+
const baseUrl = getServerBaseUrl(req);
|
|
628
|
+
const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
|
|
629
|
+
return `Bearer resource_metadata="${metadataUrl}"`;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* MCP POST endpoint - handles JSON-RPC requests
|
|
633
|
+
*/
|
|
634
|
+
app.post("/mcp", async (req, res) => {
|
|
635
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
636
|
+
const authToken = getAuthToken(req);
|
|
637
|
+
try {
|
|
638
|
+
// Check for existing session
|
|
639
|
+
if (sessionId && sessions[sessionId]) {
|
|
640
|
+
const session = sessions[sessionId];
|
|
641
|
+
await session.transport.handleRequest(req, res, req.body);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
// New session - requires authentication and must be initialization request
|
|
645
|
+
if (!authToken) {
|
|
646
|
+
res.status(401);
|
|
647
|
+
res.setHeader("WWW-Authenticate", buildWwwAuthenticateHeader(req));
|
|
648
|
+
res.json({
|
|
649
|
+
jsonrpc: "2.0",
|
|
650
|
+
error: {
|
|
651
|
+
code: -32001,
|
|
652
|
+
message: "Authentication required. Provide either X-Nestr-API-Key header or Authorization: Bearer <token> header.",
|
|
653
|
+
},
|
|
654
|
+
id: req.body?.id ?? null,
|
|
655
|
+
});
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (!isInitializeRequest(req.body)) {
|
|
659
|
+
res.status(400).json({
|
|
660
|
+
jsonrpc: "2.0",
|
|
661
|
+
error: {
|
|
662
|
+
code: -32000,
|
|
663
|
+
message: "Bad Request: No valid session ID provided, and request is not an initialization request",
|
|
664
|
+
},
|
|
665
|
+
id: req.body?.id ?? null,
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// Extract MCP client info from initialize request for tracking
|
|
670
|
+
const mcpClientName = req.body?.params?.clientInfo?.name;
|
|
671
|
+
if (mcpClientName) {
|
|
672
|
+
console.log(`MCP client: ${mcpClientName}`);
|
|
673
|
+
}
|
|
674
|
+
// Create new session with the auth token and MCP client info
|
|
675
|
+
const client = new NestrClient({
|
|
676
|
+
apiKey: authToken,
|
|
677
|
+
mcpClient: mcpClientName,
|
|
678
|
+
});
|
|
679
|
+
const server = createServer({ client });
|
|
680
|
+
const transport = new StreamableHTTPServerTransport({
|
|
681
|
+
sessionIdGenerator: () => randomUUID(),
|
|
682
|
+
onsessioninitialized: (newSessionId) => {
|
|
683
|
+
console.log(`Session initialized: ${newSessionId}${mcpClientName ? ` (client: ${mcpClientName})` : ""}`);
|
|
684
|
+
sessions[newSessionId] = { transport, server, authToken, mcpClient: mcpClientName };
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
transport.onclose = () => {
|
|
688
|
+
const sid = transport.sessionId;
|
|
689
|
+
if (sid && sessions[sid]) {
|
|
690
|
+
console.log(`Session closed: ${sid}`);
|
|
691
|
+
delete sessions[sid];
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
await server.connect(transport);
|
|
695
|
+
await transport.handleRequest(req, res, req.body);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
console.error("Error handling MCP POST request:", error);
|
|
699
|
+
if (!res.headersSent) {
|
|
700
|
+
res.status(500).json({
|
|
701
|
+
jsonrpc: "2.0",
|
|
702
|
+
error: {
|
|
703
|
+
code: -32603,
|
|
704
|
+
message: error instanceof Error ? error.message : "Internal server error",
|
|
705
|
+
},
|
|
706
|
+
id: req.body?.id ?? null,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
/**
|
|
712
|
+
* MCP GET endpoint - handles SSE streams for server-initiated messages
|
|
713
|
+
*/
|
|
714
|
+
app.get("/mcp", async (req, res) => {
|
|
715
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
716
|
+
if (!sessionId || !sessions[sessionId]) {
|
|
717
|
+
res.status(400).json({
|
|
718
|
+
jsonrpc: "2.0",
|
|
719
|
+
error: {
|
|
720
|
+
code: -32000,
|
|
721
|
+
message: "Invalid or missing session ID",
|
|
722
|
+
},
|
|
723
|
+
id: null,
|
|
724
|
+
});
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
console.log(`SSE stream requested for session: ${sessionId}`);
|
|
728
|
+
const session = sessions[sessionId];
|
|
729
|
+
try {
|
|
730
|
+
await session.transport.handleRequest(req, res);
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
console.error("Error handling MCP GET request:", error);
|
|
734
|
+
if (!res.headersSent) {
|
|
735
|
+
res.status(500).json({
|
|
736
|
+
jsonrpc: "2.0",
|
|
737
|
+
error: {
|
|
738
|
+
code: -32603,
|
|
739
|
+
message: "Internal server error",
|
|
740
|
+
},
|
|
741
|
+
id: null,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
/**
|
|
747
|
+
* MCP DELETE endpoint - handles session termination
|
|
748
|
+
*/
|
|
749
|
+
app.delete("/mcp", async (req, res) => {
|
|
750
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
751
|
+
if (!sessionId || !sessions[sessionId]) {
|
|
752
|
+
res.status(400).json({
|
|
753
|
+
jsonrpc: "2.0",
|
|
754
|
+
error: {
|
|
755
|
+
code: -32000,
|
|
756
|
+
message: "Invalid or missing session ID",
|
|
757
|
+
},
|
|
758
|
+
id: null,
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
console.log(`Session termination requested: ${sessionId}`);
|
|
763
|
+
const session = sessions[sessionId];
|
|
764
|
+
try {
|
|
765
|
+
await session.transport.handleRequest(req, res);
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
console.error("Error handling MCP DELETE request:", error);
|
|
769
|
+
if (!res.headersSent) {
|
|
770
|
+
res.status(500).json({
|
|
771
|
+
jsonrpc: "2.0",
|
|
772
|
+
error: {
|
|
773
|
+
code: -32603,
|
|
774
|
+
message: "Internal server error",
|
|
775
|
+
},
|
|
776
|
+
id: null,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
// Start server
|
|
782
|
+
app.listen(PORT, () => {
|
|
783
|
+
console.log(`Nestr MCP server listening on port ${PORT}`);
|
|
784
|
+
console.log(`Landing page: http://localhost:${PORT}`);
|
|
785
|
+
console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
|
|
786
|
+
console.log(`OAuth login: http://localhost:${PORT}/oauth/authorize`);
|
|
787
|
+
console.log(`Health check: http://localhost:${PORT}/health`);
|
|
788
|
+
const config = getOAuthConfig();
|
|
789
|
+
if (!config.clientId) {
|
|
790
|
+
console.log(`\nNote: OAuth flow disabled (NESTR_OAUTH_CLIENT_ID not set)`);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
// Handle server shutdown
|
|
794
|
+
process.on("SIGINT", async () => {
|
|
795
|
+
console.log("\nShutting down server...");
|
|
796
|
+
for (const sessionId in sessions) {
|
|
797
|
+
try {
|
|
798
|
+
console.log(`Closing session: ${sessionId}`);
|
|
799
|
+
await sessions[sessionId].transport.close();
|
|
800
|
+
await sessions[sessionId].server.close();
|
|
801
|
+
delete sessions[sessionId];
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
console.error(`Error closing session ${sessionId}:`, error);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
console.log("Server shutdown complete");
|
|
808
|
+
process.exit(0);
|
|
809
|
+
});
|
|
810
|
+
//# sourceMappingURL=http.js.map
|