@nestr/mcp 0.1.71 → 0.1.73
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/build/api/client.d.ts +54 -3
- package/build/api/client.d.ts.map +1 -1
- package/build/api/client.js +117 -13
- package/build/api/client.js.map +1 -1
- package/build/help/topics.d.ts.map +1 -1
- package/build/help/topics.js +12 -0
- package/build/help/topics.js.map +1 -1
- package/build/http.d.ts +22 -0
- package/build/http.d.ts.map +1 -1
- package/build/http.js +233 -41
- package/build/http.js.map +1 -1
- package/build/oauth/flow.d.ts +6 -0
- package/build/oauth/flow.d.ts.map +1 -1
- package/build/oauth/flow.js +30 -4
- package/build/oauth/flow.js.map +1 -1
- package/build/oauth/store.d.ts +14 -0
- package/build/oauth/store.d.ts.map +1 -1
- package/build/oauth/store.js.map +1 -1
- package/build/server.d.ts +4 -0
- package/build/server.d.ts.map +1 -1
- package/build/server.js +15 -1
- package/build/server.js.map +1 -1
- package/build/tools/index.d.ts +88 -78
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +122 -32
- package/build/tools/index.js.map +1 -1
- package/build/util/diagnose.d.ts +40 -0
- package/build/util/diagnose.d.ts.map +1 -0
- package/build/util/diagnose.js +26 -0
- package/build/util/diagnose.js.map +1 -0
- package/build/util/request-context.d.ts +11 -0
- package/build/util/request-context.d.ts.map +1 -0
- package/build/util/request-context.js +30 -0
- package/build/util/request-context.js.map +1 -0
- package/package.json +1 -1
package/build/http.js
CHANGED
|
@@ -33,7 +33,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
33
33
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
34
34
|
import { createServer } from "./server.js";
|
|
35
35
|
import { toolDefinitions } from "./tools/index.js";
|
|
36
|
-
import { NestrClient, NestrApiError } from "./api/client.js";
|
|
36
|
+
import { NestrClient, NestrApiError, tokenFingerprint } from "./api/client.js";
|
|
37
37
|
import { getProtectedResourceMetadata, getAuthorizationServerMetadata, getOAuthConfig, } from "./oauth/config.js";
|
|
38
38
|
import { createAuthorizationRequest, getPendingAuth, exchangeCodeForTokens, storeOAuthSession, verifyPKCE, getOAuthSession, } from "./oauth/flow.js";
|
|
39
39
|
import { initStore, getStore, } from "./oauth/store.js";
|
|
@@ -46,6 +46,8 @@ import path from "path";
|
|
|
46
46
|
import fs from "fs";
|
|
47
47
|
import { fileURLToPath } from "url";
|
|
48
48
|
import { VERSION } from "./version.js";
|
|
49
|
+
import { runWithContext, cidTag } from "./util/request-context.js";
|
|
50
|
+
import { tryDecodeJwtAge } from "./util/diagnose.js";
|
|
49
51
|
const __filename = fileURLToPath(import.meta.url);
|
|
50
52
|
const __dirname = path.dirname(__filename);
|
|
51
53
|
const PORT = process.env.PORT || 3000;
|
|
@@ -322,6 +324,10 @@ app.get("/oauth/authorize", oauthLimiter, async (req, res) => {
|
|
|
322
324
|
const codeChallenge = req.query.code_challenge;
|
|
323
325
|
const codeChallengeMethod = req.query.code_challenge_method;
|
|
324
326
|
const clientConsumer = req.query.client_consumer;
|
|
327
|
+
// MCP `clientInfo.version` forwarded from the initialize handshake. Slashme-online
|
|
328
|
+
// persists it on the issued token row (see PR #1392) for triage / outdated-install
|
|
329
|
+
// detection. We just plumb the URL param through.
|
|
330
|
+
const clientVersion = req.query.client_version;
|
|
325
331
|
// GA4 analytics: use provided client_id or generate new one for tracking
|
|
326
332
|
const gaClientId = req.query._ga_client_id ||
|
|
327
333
|
(analytics.isEnabled() ? analytics.generateClientId() : undefined);
|
|
@@ -391,6 +397,7 @@ app.get("/oauth/authorize", oauthLimiter, async (req, res) => {
|
|
|
391
397
|
codeChallenge,
|
|
392
398
|
codeChallengeMethod: codeChallengeMethod || "S256",
|
|
393
399
|
clientConsumer: effectiveClientConsumer,
|
|
400
|
+
clientVersion,
|
|
394
401
|
gaClientId,
|
|
395
402
|
});
|
|
396
403
|
// Override the redirect_uri in the auth URL to use OUR callback
|
|
@@ -610,7 +617,7 @@ app.get("/oauth/callback", async (req, res) => {
|
|
|
610
617
|
app.post("/oauth/device", oauthLimiter, express.urlencoded({ extended: true }), async (req, res) => {
|
|
611
618
|
const config = getOAuthConfig();
|
|
612
619
|
try {
|
|
613
|
-
const { client_id, scope, client_consumer } = req.body;
|
|
620
|
+
const { client_id, scope, client_consumer, client_version } = req.body;
|
|
614
621
|
// Require client_id
|
|
615
622
|
if (!client_id) {
|
|
616
623
|
res.status(400).json({
|
|
@@ -653,7 +660,11 @@ app.post("/oauth/device", oauthLimiter, express.urlencoded({ extended: true }),
|
|
|
653
660
|
if (effectiveClientConsumer) {
|
|
654
661
|
body.client_consumer = effectiveClientConsumer;
|
|
655
662
|
}
|
|
656
|
-
|
|
663
|
+
// Pass client_version (MCP `clientInfo.version`) for triage / outdated-install detection.
|
|
664
|
+
if (client_version) {
|
|
665
|
+
body.client_version = client_version;
|
|
666
|
+
}
|
|
667
|
+
console.log(`OAuth Device: Requesting device code from Nestr${effectiveClientConsumer ? ` (consumer: ${effectiveClientConsumer})` : ""}${client_version ? ` v${client_version}` : ""}`);
|
|
657
668
|
const response = await fetch(config.deviceAuthorizationEndpoint, {
|
|
658
669
|
method: "POST",
|
|
659
670
|
headers: {
|
|
@@ -691,7 +702,7 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
|
|
|
691
702
|
const config = getOAuthConfig();
|
|
692
703
|
try {
|
|
693
704
|
// Get form body params
|
|
694
|
-
const { grant_type, code, redirect_uri, refresh_token, client_id, client_secret, code_verifier, client_consumer, } = req.body;
|
|
705
|
+
const { grant_type, code, redirect_uri, refresh_token, client_id, client_secret, code_verifier, client_consumer, client_version, } = req.body;
|
|
695
706
|
if (grant_type === "authorization_code") {
|
|
696
707
|
if (!code) {
|
|
697
708
|
res.status(400).json({
|
|
@@ -763,6 +774,12 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
|
|
|
763
774
|
if (client_consumer) {
|
|
764
775
|
body.client_consumer = client_consumer;
|
|
765
776
|
}
|
|
777
|
+
// Pass client_version (MCP `clientInfo.version`) — slashme-online persists
|
|
778
|
+
// it on the token row alongside the consumer for triage / outdated-install
|
|
779
|
+
// detection. Ignored upstream until that PR lands.
|
|
780
|
+
if (client_version) {
|
|
781
|
+
body.client_version = client_version;
|
|
782
|
+
}
|
|
766
783
|
const response = await fetch(config.tokenEndpoint, {
|
|
767
784
|
method: "POST",
|
|
768
785
|
headers: {
|
|
@@ -826,6 +843,9 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
|
|
|
826
843
|
if (client_consumer) {
|
|
827
844
|
body.client_consumer = client_consumer;
|
|
828
845
|
}
|
|
846
|
+
if (client_version) {
|
|
847
|
+
body.client_version = client_version;
|
|
848
|
+
}
|
|
829
849
|
console.log(`OAuth Token: Polling device code at Nestr${client_consumer ? ` (consumer: ${client_consumer})` : ""}`);
|
|
830
850
|
const response = await fetch(config.tokenEndpoint, {
|
|
831
851
|
method: "POST",
|
|
@@ -851,6 +871,10 @@ app.post("/oauth/token", tokenLimiter, express.urlencoded({ extended: true }), a
|
|
|
851
871
|
});
|
|
852
872
|
}
|
|
853
873
|
});
|
|
874
|
+
// How long a successful upstream auth check is good for. 60s is enough to
|
|
875
|
+
// amortize the probe across a normal tool burst without letting a freshly
|
|
876
|
+
// revoked token slip past for too long.
|
|
877
|
+
const AUTH_VERIFY_TTL_MS = 60 * 1000;
|
|
854
878
|
export const sessions = {};
|
|
855
879
|
let shuttingDown = false;
|
|
856
880
|
let inFlightRequests = 0;
|
|
@@ -994,29 +1018,43 @@ function cacheIdentity(token, userId, userName) {
|
|
|
994
1018
|
function buildMcpSession(opts) {
|
|
995
1019
|
const sessionStartTime = Date.now();
|
|
996
1020
|
let sessionRef;
|
|
1021
|
+
const flow = opts.hasStoredOAuthSession ? "A" : opts.isApiKey ? "unknown" : "B";
|
|
997
1022
|
const client = new NestrClient({
|
|
998
1023
|
apiKey: opts.authToken,
|
|
999
1024
|
baseUrl: process.env.NESTR_API_BASE,
|
|
1000
1025
|
mcpClient: opts.mcpClient,
|
|
1001
|
-
|
|
1026
|
+
flow,
|
|
1027
|
+
// tokenProvider enables server-side token refresh for stored sessions (Flow A).
|
|
1002
1028
|
// In the standard MCP OAuth flow there's no stored session — the client manages refresh.
|
|
1003
1029
|
// When tokenProvider is undefined, NestrClient lets 401 propagate to the client.
|
|
1004
1030
|
tokenProvider: opts.hasStoredOAuthSession ? async () => {
|
|
1005
1031
|
const session = await getOAuthSession(opts.authToken);
|
|
1006
1032
|
if (!session) {
|
|
1007
|
-
// Stored session expired and refresh failed — surface a
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1033
|
+
// Stored session expired and refresh failed — surface a typed
|
|
1034
|
+
// AUTH_REFRESH_FAILED so the client can branch on it. The HTTP-level
|
|
1035
|
+
// pre-check in the POST handler normally catches this first; this is
|
|
1036
|
+
// the in-flight fallback.
|
|
1011
1037
|
const sid = sessionRef?.transport?.sessionId;
|
|
1012
|
-
console.log(
|
|
1038
|
+
console.log(`${cidTag()}OAuth session expired mid-session (MCP session: ${sid ?? "unknown"}). Returning AUTH_REFRESH_FAILED to client.`);
|
|
1013
1039
|
throw new NestrApiError("OAuth session expired", 401, "/", {
|
|
1014
|
-
code: "
|
|
1015
|
-
|
|
1040
|
+
code: "AUTH_REFRESH_FAILED",
|
|
1041
|
+
flow: "A",
|
|
1042
|
+
hint: "Server-side refresh failed (session expired or server was restarted). User must reconnect Nestr to re-authenticate.",
|
|
1016
1043
|
});
|
|
1017
1044
|
}
|
|
1018
1045
|
return session.accessToken;
|
|
1019
1046
|
} : undefined,
|
|
1047
|
+
onUpstreamAuthFailure: () => {
|
|
1048
|
+
if (sessionRef) {
|
|
1049
|
+
sessionRef.authInvalidated = true;
|
|
1050
|
+
sessionRef.lastUpstream401At = Date.now();
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
onRefreshAttempt: (result) => {
|
|
1054
|
+
if (sessionRef) {
|
|
1055
|
+
sessionRef.lastRefreshAttempt = result;
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1020
1058
|
});
|
|
1021
1059
|
const server = createServer({
|
|
1022
1060
|
client,
|
|
@@ -1040,18 +1078,33 @@ function buildMcpSession(opts) {
|
|
|
1040
1078
|
console.error("[Analytics] Tool call tracking error:", e);
|
|
1041
1079
|
}
|
|
1042
1080
|
},
|
|
1081
|
+
getDiagnose: () => ({
|
|
1082
|
+
flow: opts.hasStoredOAuthSession ? "A" : opts.isApiKey ? "unknown" : "B",
|
|
1083
|
+
tokenPresented: !!opts.authToken,
|
|
1084
|
+
tokenFingerprint: tokenFingerprint(opts.authToken),
|
|
1085
|
+
tokenAge: tryDecodeJwtAge(opts.authToken),
|
|
1086
|
+
lastUpstream401At: sessionRef?.lastUpstream401At,
|
|
1087
|
+
lastRefreshAttempt: sessionRef?.lastRefreshAttempt,
|
|
1088
|
+
sessionCorrelationId: sessionRef?.sessionCorrelationId,
|
|
1089
|
+
hasStoredOAuthSession: opts.hasStoredOAuthSession,
|
|
1090
|
+
isApiKey: opts.isApiKey,
|
|
1091
|
+
mcpClient: opts.mcpClient,
|
|
1092
|
+
mcpClientVersion: opts.mcpClientVersion,
|
|
1093
|
+
userId: opts.userId,
|
|
1094
|
+
}),
|
|
1043
1095
|
});
|
|
1044
1096
|
const transport = new StreamableHTTPServerTransport({
|
|
1045
1097
|
sessionIdGenerator: () => opts.rehydrateFor ?? randomUUID(),
|
|
1046
1098
|
enableJsonResponse: opts.wantsJsonOnly,
|
|
1047
1099
|
onsessioninitialized: (newSessionId) => {
|
|
1048
1100
|
// Only fires for fresh sessions — rehydrated transports skip the handshake.
|
|
1049
|
-
console.log(
|
|
1101
|
+
console.log(`${cidTag()}Session initialized: ${newSessionId}${opts.mcpClient ? ` (client: ${opts.mcpClient}${opts.mcpClientVersion ? ` v${opts.mcpClientVersion}` : ""})` : ""}`);
|
|
1050
1102
|
const sessionData = {
|
|
1051
1103
|
transport,
|
|
1052
1104
|
server,
|
|
1053
1105
|
authToken: opts.authToken,
|
|
1054
1106
|
mcpClient: opts.mcpClient,
|
|
1107
|
+
mcpClientVersion: opts.mcpClientVersion,
|
|
1055
1108
|
isApiKey: opts.isApiKey,
|
|
1056
1109
|
wantsJsonOnly: opts.wantsJsonOnly,
|
|
1057
1110
|
hasStoredOAuthSession: opts.hasStoredOAuthSession,
|
|
@@ -1062,6 +1115,7 @@ function buildMcpSession(opts) {
|
|
|
1062
1115
|
sessionStartTime,
|
|
1063
1116
|
lastActivityAt: Date.now(),
|
|
1064
1117
|
lastPersistedAt: Date.now(),
|
|
1118
|
+
sessionCorrelationId: randomUUID(),
|
|
1065
1119
|
};
|
|
1066
1120
|
sessions[newSessionId] = sessionData;
|
|
1067
1121
|
sessionRef = sessionData;
|
|
@@ -1069,6 +1123,7 @@ function buildMcpSession(opts) {
|
|
|
1069
1123
|
getStore().storeMcpSession(newSessionId, {
|
|
1070
1124
|
authToken: opts.authToken,
|
|
1071
1125
|
mcpClient: opts.mcpClient,
|
|
1126
|
+
mcpClientVersion: opts.mcpClientVersion,
|
|
1072
1127
|
userId: opts.userId,
|
|
1073
1128
|
userName: opts.userName,
|
|
1074
1129
|
isApiKey: opts.isApiKey,
|
|
@@ -1131,6 +1186,7 @@ function buildMcpSession(opts) {
|
|
|
1131
1186
|
server,
|
|
1132
1187
|
authToken: opts.authToken,
|
|
1133
1188
|
mcpClient: opts.mcpClient,
|
|
1189
|
+
mcpClientVersion: opts.mcpClientVersion,
|
|
1134
1190
|
isApiKey: opts.isApiKey,
|
|
1135
1191
|
wantsJsonOnly: opts.wantsJsonOnly,
|
|
1136
1192
|
hasStoredOAuthSession: opts.hasStoredOAuthSession,
|
|
@@ -1141,10 +1197,11 @@ function buildMcpSession(opts) {
|
|
|
1141
1197
|
sessionStartTime,
|
|
1142
1198
|
lastActivityAt: Date.now(),
|
|
1143
1199
|
lastPersistedAt: Date.now(),
|
|
1200
|
+
sessionCorrelationId: randomUUID(),
|
|
1144
1201
|
};
|
|
1145
1202
|
sessions[opts.rehydrateFor] = sessionData;
|
|
1146
1203
|
sessionRef = sessionData;
|
|
1147
|
-
console.log(
|
|
1204
|
+
console.log(`${cidTag()}[Rehydrate] Rebuilt session ${opts.rehydrateFor} (client: ${opts.mcpClient ?? "unknown"}${opts.mcpClientVersion ? ` v${opts.mcpClientVersion}` : ""})`);
|
|
1148
1205
|
return sessionData;
|
|
1149
1206
|
}
|
|
1150
1207
|
// Fresh session: caller still needs to attach via server.connect(transport)
|
|
@@ -1154,6 +1211,7 @@ function buildMcpSession(opts) {
|
|
|
1154
1211
|
server,
|
|
1155
1212
|
authToken: opts.authToken,
|
|
1156
1213
|
mcpClient: opts.mcpClient,
|
|
1214
|
+
mcpClientVersion: opts.mcpClientVersion,
|
|
1157
1215
|
isApiKey: opts.isApiKey,
|
|
1158
1216
|
wantsJsonOnly: opts.wantsJsonOnly,
|
|
1159
1217
|
hasStoredOAuthSession: opts.hasStoredOAuthSession,
|
|
@@ -1202,6 +1260,7 @@ async function rehydrateSession(sessionId, authToken) {
|
|
|
1202
1260
|
authToken: stored.authToken,
|
|
1203
1261
|
isApiKey: stored.isApiKey,
|
|
1204
1262
|
mcpClient: stored.mcpClient,
|
|
1263
|
+
mcpClientVersion: stored.mcpClientVersion,
|
|
1205
1264
|
userId: stored.userId,
|
|
1206
1265
|
userName: stored.userName,
|
|
1207
1266
|
wantsJsonOnly: stored.wantsJsonOnly,
|
|
@@ -1244,13 +1303,144 @@ export function getAuthToken(req) {
|
|
|
1244
1303
|
return null;
|
|
1245
1304
|
}
|
|
1246
1305
|
/**
|
|
1247
|
-
* Build WWW-Authenticate header for 401 responses
|
|
1248
|
-
*
|
|
1306
|
+
* Build WWW-Authenticate header for 401 responses (RFC 6750 + RFC 9728).
|
|
1307
|
+
*
|
|
1308
|
+
* `resource_metadata` points the MCP client at our metadata endpoint so it can
|
|
1309
|
+
* discover the authorization server. `error` / `error_description` follow
|
|
1310
|
+
* RFC 6750 §3 so a SDK that surfaces the header can display a useful reason.
|
|
1249
1311
|
*/
|
|
1250
|
-
function buildWwwAuthenticateHeader(req) {
|
|
1312
|
+
function buildWwwAuthenticateHeader(req, options = {}) {
|
|
1251
1313
|
const baseUrl = getServerBaseUrl(req);
|
|
1252
1314
|
const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
|
|
1253
|
-
|
|
1315
|
+
const parts = [`resource_metadata="${metadataUrl}"`];
|
|
1316
|
+
if (options.error) {
|
|
1317
|
+
parts.push(`error="${options.error}"`);
|
|
1318
|
+
}
|
|
1319
|
+
if (options.errorDescription) {
|
|
1320
|
+
// Keep description ASCII-safe; strip quotes to avoid breaking the header.
|
|
1321
|
+
parts.push(`error_description="${options.errorDescription.replace(/"/g, "'")}"`);
|
|
1322
|
+
}
|
|
1323
|
+
return `Bearer ${parts.join(", ")}`;
|
|
1324
|
+
}
|
|
1325
|
+
function isToolCallRequest(body) {
|
|
1326
|
+
return !!body && typeof body === "object" && body.method === "tools/call";
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Pre-flight upstream auth check.
|
|
1330
|
+
*
|
|
1331
|
+
* Goal: when the bearer is dead, we want to respond with HTTP 401 +
|
|
1332
|
+
* WWW-Authenticate (which triggers the MCP SDK's auto-refresh) instead of a
|
|
1333
|
+
* `tools/call` JSON-RPC result with `isError: true` (which is invisible to
|
|
1334
|
+
* the SDK's auth machinery — that was the original Cowork bug).
|
|
1335
|
+
*
|
|
1336
|
+
* Strategy:
|
|
1337
|
+
* 1. If `session.authInvalidated` is set, a previous in-flight call already
|
|
1338
|
+
* saw a 401. Return HTTP 401 immediately for any method (tools/call,
|
|
1339
|
+
* tools/list, ping…) so the client knows to re-auth. Clear the flag so a
|
|
1340
|
+
* subsequent request (after the client refreshes) can re-verify.
|
|
1341
|
+
* 2. Non-tool methods (tools/list, ping, resources/*) don't talk to Nestr.
|
|
1342
|
+
* Skip the upstream probe entirely — the next tools/call will catch any
|
|
1343
|
+
* expired session.
|
|
1344
|
+
* 3. For tools/call, probe Nestr with a cheap, idempotent call:
|
|
1345
|
+
* - Flow A (stored OAuth session): refresh-or-return via getOAuthSession.
|
|
1346
|
+
* - Flow B (Bearer, no stored session): GET /users/me, cached 60s.
|
|
1347
|
+
* - API key: GET /workspaces?limit=1, cached 60s.
|
|
1348
|
+
* A 200 → cache verified-at-now (Flow B/API key). A 401 → return HTTP 401.
|
|
1349
|
+
*
|
|
1350
|
+
* Skipped for `nestr_diagnose` since that tool's whole purpose is to be
|
|
1351
|
+
* callable without auth.
|
|
1352
|
+
*/
|
|
1353
|
+
async function preflightAuthCheck(req, res, session, toolName, isToolCall) {
|
|
1354
|
+
if (toolName === "nestr_diagnose")
|
|
1355
|
+
return { blocked: false };
|
|
1356
|
+
const replyWith401 = (errorDescription) => {
|
|
1357
|
+
res.status(401);
|
|
1358
|
+
res.setHeader("WWW-Authenticate", buildWwwAuthenticateHeader(req, { error: "invalid_token", errorDescription }));
|
|
1359
|
+
res.json({
|
|
1360
|
+
jsonrpc: "2.0",
|
|
1361
|
+
error: {
|
|
1362
|
+
code: -32001,
|
|
1363
|
+
message: "Authentication required. Token rejected by Nestr.",
|
|
1364
|
+
data: {
|
|
1365
|
+
flow: session.hasStoredOAuthSession ? "A" : session.isApiKey ? "unknown" : "B",
|
|
1366
|
+
hint: session.hasStoredOAuthSession
|
|
1367
|
+
? "Server-side refresh failed. Reconnect Nestr to re-authenticate."
|
|
1368
|
+
: session.isApiKey
|
|
1369
|
+
? "API key was rejected. Regenerate the workspace API key."
|
|
1370
|
+
: "Bearer was rejected by Nestr. Refresh via /oauth/token, or run a fresh OAuth flow if refresh also fails.",
|
|
1371
|
+
correlationId: session.sessionCorrelationId,
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
id: req.body?.id ?? null,
|
|
1375
|
+
});
|
|
1376
|
+
};
|
|
1377
|
+
// Always-on: a previous tool call on this session saw upstream 401.
|
|
1378
|
+
// Whatever method is being called now, fail fast so the client re-auths.
|
|
1379
|
+
// This stays unconditional (cheap — just a flag check) so an in-flight 401
|
|
1380
|
+
// on a tool call surfaces as transport 401 on whatever the *next* request
|
|
1381
|
+
// happens to be, even if it's a `tools/list` or `ping`.
|
|
1382
|
+
if (session.authInvalidated) {
|
|
1383
|
+
console.log(`${cidTag()}[Preflight] session previously saw upstream 401 → returning HTTP 401`);
|
|
1384
|
+
session.authInvalidated = false; // one-shot: clear so a refreshed retry can re-verify
|
|
1385
|
+
session.lastAuthVerifiedAt = undefined;
|
|
1386
|
+
replyWith401("Bearer rejected by Nestr on a prior request");
|
|
1387
|
+
return { blocked: true };
|
|
1388
|
+
}
|
|
1389
|
+
// Non-tool calls (`tools/list`, `ping`, `resources/*`, etc.) don't talk to
|
|
1390
|
+
// Nestr, so they don't need an upstream auth check. Skip out before any
|
|
1391
|
+
// Redis read. The next `tools/call` will catch any expired session.
|
|
1392
|
+
if (!isToolCall)
|
|
1393
|
+
return { blocked: false };
|
|
1394
|
+
// Flow A: refresh-or-return without an extra Nestr round-trip.
|
|
1395
|
+
// getOAuthSession refreshes if the access token is near expiry; if the
|
|
1396
|
+
// refresh fails the session is wiped and we surface the 401.
|
|
1397
|
+
if (session.hasStoredOAuthSession) {
|
|
1398
|
+
try {
|
|
1399
|
+
const oauthSession = await getOAuthSession(session.authToken);
|
|
1400
|
+
if (!oauthSession) {
|
|
1401
|
+
replyWith401("Stored OAuth session has expired and could not be refreshed");
|
|
1402
|
+
return { blocked: true };
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
catch (e) {
|
|
1406
|
+
console.error(`${cidTag()}[Preflight] Flow A check failed:`, e instanceof Error ? e.message : e);
|
|
1407
|
+
replyWith401("Stored OAuth session could not be revalidated");
|
|
1408
|
+
return { blocked: true };
|
|
1409
|
+
}
|
|
1410
|
+
// Flow A is satisfied without hitting Nestr; no Flow B probe needed.
|
|
1411
|
+
return { blocked: false };
|
|
1412
|
+
}
|
|
1413
|
+
const now = Date.now();
|
|
1414
|
+
if (session.lastAuthVerifiedAt && now - session.lastAuthVerifiedAt < AUTH_VERIFY_TTL_MS) {
|
|
1415
|
+
return { blocked: false };
|
|
1416
|
+
}
|
|
1417
|
+
// Flow B / API key: probe upstream with a cheap call.
|
|
1418
|
+
try {
|
|
1419
|
+
const probe = new NestrClient({
|
|
1420
|
+
apiKey: session.authToken,
|
|
1421
|
+
baseUrl: process.env.NESTR_API_BASE,
|
|
1422
|
+
flow: session.isApiKey ? "unknown" : "B",
|
|
1423
|
+
});
|
|
1424
|
+
if (session.isApiKey) {
|
|
1425
|
+
await probe.listWorkspaces({ limit: 1 });
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
await probe.getCurrentUser();
|
|
1429
|
+
}
|
|
1430
|
+
session.lastAuthVerifiedAt = now;
|
|
1431
|
+
return { blocked: false };
|
|
1432
|
+
}
|
|
1433
|
+
catch (e) {
|
|
1434
|
+
if (e instanceof NestrApiError && e.status === 401) {
|
|
1435
|
+
session.lastUpstream401At = now;
|
|
1436
|
+
replyWith401(e.message);
|
|
1437
|
+
return { blocked: true };
|
|
1438
|
+
}
|
|
1439
|
+
// Probe couldn't reach Nestr or hit a non-auth error. Don't fail closed —
|
|
1440
|
+
// a 5xx from Nestr or a network blip shouldn't take the user offline.
|
|
1441
|
+
console.warn(`${cidTag()}[Preflight] probe failed (non-401), allowing through:`, e instanceof Error ? e.message : e);
|
|
1442
|
+
return { blocked: false };
|
|
1443
|
+
}
|
|
1254
1444
|
}
|
|
1255
1445
|
// In-flight request tracking for /mcp so the shutdown handler can wait for
|
|
1256
1446
|
// outstanding tool calls to finish before the pod terminates.
|
|
@@ -1271,6 +1461,10 @@ app.use("/mcp", (_req, res, next) => {
|
|
|
1271
1461
|
* MCP POST endpoint - handles JSON-RPC requests
|
|
1272
1462
|
*/
|
|
1273
1463
|
app.post("/mcp", async (req, res) => {
|
|
1464
|
+
const correlationId = randomUUID();
|
|
1465
|
+
await runWithContext({ correlationId }, () => handleMcpPost(req, res));
|
|
1466
|
+
});
|
|
1467
|
+
async function handleMcpPost(req, res) {
|
|
1274
1468
|
const sessionId = req.headers["mcp-session-id"];
|
|
1275
1469
|
const authToken = getAuthToken(req);
|
|
1276
1470
|
const isApiKey = !!req.headers["x-nestr-api-key"];
|
|
@@ -1311,26 +1505,18 @@ app.post("/mcp", async (req, res) => {
|
|
|
1311
1505
|
});
|
|
1312
1506
|
return;
|
|
1313
1507
|
}
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
code: -32001,
|
|
1327
|
-
message: "OAuth session expired. Reconnect to re-authenticate.",
|
|
1328
|
-
},
|
|
1329
|
-
id: req.body?.id ?? null,
|
|
1330
|
-
});
|
|
1331
|
-
return;
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1508
|
+
// Pre-flight upstream auth check. For Flow A this leans on the existing
|
|
1509
|
+
// getOAuthSession refresh-or-return logic; for Flow B (Cowork etc.) it
|
|
1510
|
+
// probes Nestr with /users/me on tool calls. Either way, if the bearer
|
|
1511
|
+
// is dead we respond with HTTP 401 + WWW-Authenticate so the MCP SDK
|
|
1512
|
+
// auto-refreshes — instead of wrapping the failure as a tool error the
|
|
1513
|
+
// client ignores.
|
|
1514
|
+
const toolName = isToolCallRequest(req.body)
|
|
1515
|
+
? req.body?.params?.name
|
|
1516
|
+
: undefined;
|
|
1517
|
+
const preflight = await preflightAuthCheck(req, res, session, toolName, isToolCallRequest(req.body));
|
|
1518
|
+
if (preflight.blocked)
|
|
1519
|
+
return;
|
|
1334
1520
|
session.lastActivityAt = Date.now();
|
|
1335
1521
|
await maybeTouchMcpSession(sessionId, session);
|
|
1336
1522
|
await session.transport.handleRequest(req, res, req.body);
|
|
@@ -1382,8 +1568,13 @@ app.post("/mcp", async (req, res) => {
|
|
|
1382
1568
|
});
|
|
1383
1569
|
return;
|
|
1384
1570
|
}
|
|
1385
|
-
// Extract MCP client info early (needed for coalescing check)
|
|
1571
|
+
// Extract MCP client info early (needed for coalescing check). The MCP
|
|
1572
|
+
// initialize handshake provides a structured `clientInfo: { name, version }`;
|
|
1573
|
+
// we capture both, surface them on the session, and forward to OAuth so the
|
|
1574
|
+
// upstream OAuth server can record the version on the issued token row
|
|
1575
|
+
// (slashme-online PR #1392).
|
|
1386
1576
|
const mcpClientName = req.body?.params?.clientInfo?.name;
|
|
1577
|
+
const mcpClientVersion = req.body?.params?.clientInfo?.version;
|
|
1387
1578
|
if (!isInitializeRequest(req.body)) {
|
|
1388
1579
|
res.status(400).json({
|
|
1389
1580
|
jsonrpc: "2.0",
|
|
@@ -1507,6 +1698,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
1507
1698
|
authToken,
|
|
1508
1699
|
isApiKey,
|
|
1509
1700
|
mcpClient: mcpClientName,
|
|
1701
|
+
mcpClientVersion,
|
|
1510
1702
|
userId,
|
|
1511
1703
|
userName,
|
|
1512
1704
|
wantsJsonOnly,
|
|
@@ -1517,7 +1709,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
1517
1709
|
await session.transport.handleRequest(req, res, req.body);
|
|
1518
1710
|
}
|
|
1519
1711
|
catch (error) {
|
|
1520
|
-
console.error(
|
|
1712
|
+
console.error(`${cidTag()}Error handling MCP POST request:`, error);
|
|
1521
1713
|
if (!res.headersSent) {
|
|
1522
1714
|
res.status(500).json({
|
|
1523
1715
|
jsonrpc: "2.0",
|
|
@@ -1529,7 +1721,7 @@ app.post("/mcp", async (req, res) => {
|
|
|
1529
1721
|
});
|
|
1530
1722
|
}
|
|
1531
1723
|
}
|
|
1532
|
-
}
|
|
1724
|
+
}
|
|
1533
1725
|
/**
|
|
1534
1726
|
* MCP GET endpoint - handles SSE streams for server-initiated messages
|
|
1535
1727
|
*/
|