@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/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
- console.log(`OAuth Device: Requesting device code from Nestr${effectiveClientConsumer ? ` (consumer: ${effectiveClientConsumer})` : ""}`);
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
- // tokenProvider enables server-side token refresh for stored sessions (browser flow).
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 401 to the
1008
- // client without ripping the MCP session out from under the protocol.
1009
- // The HTTP-level pre-check in the POST handler will normally catch
1010
- // this first; this is the in-flight fallback.
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(`OAuth session expired mid-session (MCP session: ${sid ?? "unknown"}). Returning 401 to client.`);
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: "AUTH_FAILED",
1015
- hint: "Your OAuth session has expired or the server was restarted. Reconnect to the MCP server to re-authenticate.",
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(`Session initialized: ${newSessionId}${opts.mcpClient ? ` (client: ${opts.mcpClient})` : ""}`);
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(`[Rehydrate] Rebuilt session ${opts.rehydrateFor} (client: ${opts.mcpClient ?? "unknown"})`);
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
- * Directs MCP clients to the OAuth protected resource metadata
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
- return `Bearer resource_metadata="${metadataUrl}"`;
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
- // For sessions held over from a previous pod, the OAuth token may have
1315
- // expired. Pre-check before invoking the transport so we can return a
1316
- // proper HTTP 401 + WWW-Authenticate that triggers MCP client re-auth,
1317
- // instead of wrapping the failure as a tool error the client ignores.
1318
- if (session.hasStoredOAuthSession) {
1319
- const oauthSession = await getOAuthSession(session.authToken);
1320
- if (!oauthSession) {
1321
- res.status(401);
1322
- res.setHeader("WWW-Authenticate", buildWwwAuthenticateHeader(req));
1323
- res.json({
1324
- jsonrpc: "2.0",
1325
- error: {
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("Error handling MCP POST request:", 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
  */