@productbrain/mcp 0.0.1-beta.190 → 0.0.1-beta.192

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/dist/http.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  hashKey,
8
8
  initFeatureFlags,
9
9
  runWithAuth
10
- } from "./chunk-ZF6N62QQ.js";
10
+ } from "./chunk-26IS4THT.js";
11
11
  import {
12
12
  getPostHogClient,
13
13
  initAnalytics,
@@ -15,7 +15,7 @@ import {
15
15
  } from "./chunk-YMF3IQ5E.js";
16
16
 
17
17
  // src/http.ts
18
- import { createHash, randomUUID } from "crypto";
18
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
19
19
  import express from "express";
20
20
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
21
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
@@ -54,6 +54,63 @@ function appLogoMarkup(opts = {}) {
54
54
  return `<span class="${cls}"><span class="pb-logo__mark"><span class="pb-logo__core"></span></span>${wordmark}</span>`;
55
55
  }
56
56
 
57
+ // src/lib/refresh-token.ts
58
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
59
+ var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
60
+ var PREFIX = "pb_rt_";
61
+ var secret = (() => {
62
+ const fromEnv = process.env.MCP_REFRESH_SECRET;
63
+ if (fromEnv && fromEnv.length > 0) return Buffer.from(fromEnv, "utf8");
64
+ if (process.env.NODE_ENV === "production") {
65
+ console.warn(
66
+ "[HTTP] WARNING MCP_REFRESH_SECRET not set \u2014 refresh tokens will not survive restart"
67
+ );
68
+ }
69
+ return randomBytes(32);
70
+ })();
71
+ function sign(payloadB64) {
72
+ return createHmac("sha256", secret).update(payloadB64).digest();
73
+ }
74
+ function signRefreshToken(apiKey) {
75
+ const payload = {
76
+ k: apiKey,
77
+ i: Date.now(),
78
+ j: randomUUID()
79
+ };
80
+ const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
81
+ const sigB64 = sign(payloadB64).toString("base64url");
82
+ return `${PREFIX}${payloadB64}.${sigB64}`;
83
+ }
84
+ function verifyRefreshToken(token) {
85
+ if (typeof token !== "string" || !token.startsWith(PREFIX)) return null;
86
+ const body = token.slice(PREFIX.length);
87
+ const dot = body.indexOf(".");
88
+ if (dot <= 0 || dot === body.length - 1) return null;
89
+ const payloadB64 = body.slice(0, dot);
90
+ const sigB64 = body.slice(dot + 1);
91
+ let providedSig;
92
+ try {
93
+ providedSig = Buffer.from(sigB64, "base64url");
94
+ } catch {
95
+ return null;
96
+ }
97
+ const expectedSig = sign(payloadB64);
98
+ if (providedSig.length !== expectedSig.length) return null;
99
+ if (!timingSafeEqual(providedSig, expectedSig)) return null;
100
+ let payload;
101
+ try {
102
+ const json = Buffer.from(payloadB64, "base64url").toString("utf8");
103
+ payload = JSON.parse(json);
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (!payload || typeof payload.k !== "string" || typeof payload.i !== "number" || typeof payload.j !== "string") {
108
+ return null;
109
+ }
110
+ if (Date.now() - payload.i > REFRESH_TOKEN_TTL_MS) return null;
111
+ return { apiKey: payload.k };
112
+ }
113
+
57
114
  // src/http.ts
58
115
  bootstrapHttp();
59
116
  initAnalytics();
@@ -137,7 +194,7 @@ app.post(
137
194
  });
138
195
  return;
139
196
  }
140
- const clientId = `pb_client_${randomUUID()}`;
197
+ const clientId = `pb_client_${randomUUID2()}`;
141
198
  const client = {
142
199
  client_id: clientId,
143
200
  redirect_uris,
@@ -158,10 +215,6 @@ app.post(
158
215
  var pendingCodes = /* @__PURE__ */ new Map();
159
216
  var ACCESS_TOKEN_TTL = 3600;
160
217
  var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
161
- var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
162
- var refreshTokens = /* @__PURE__ */ new Map();
163
- var MAX_REFRESH_TOKENS = 2e3;
164
- var MAX_REFRESH_TOKENS_PER_KEY = 20;
165
218
  var accessTokens = /* @__PURE__ */ new Map();
166
219
  setInterval(() => {
167
220
  const now = Date.now();
@@ -171,15 +224,6 @@ setInterval(() => {
171
224
  for (const [id, client] of registeredClients) {
172
225
  if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
173
226
  }
174
- for (const [token, entry] of refreshTokens) {
175
- if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
176
- }
177
- if (refreshTokens.size > MAX_REFRESH_TOKENS) {
178
- const sorted = [...refreshTokens.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
179
- for (let i = 0; i < sorted.length - MAX_REFRESH_TOKENS; i++) {
180
- refreshTokens.delete(sorted[i][0]);
181
- }
182
- }
183
227
  for (const [token, entry] of accessTokens) {
184
228
  if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
185
229
  }
@@ -415,12 +459,22 @@ ${appLogoStyles}
415
459
  color:var(--fg-bright);opacity:0;
416
460
  }
417
461
  .panel:not([hidden]) .ok-title{animation:rise 600ms ease-out 380ms forwards}
418
- .ok-sentence{
419
- margin:20px auto 0;font-size:16px;line-height:1.6;color:var(--fg2);
462
+ .ok-lead{
463
+ margin:18px auto 0;max-width:22em;font-size:16px;line-height:1.55;color:var(--fg2);
420
464
  opacity:0;
421
- display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;
422
465
  }
423
- .panel:not([hidden]) .ok-sentence{animation:rise 600ms ease-out 520ms forwards}
466
+ .panel:not([hidden]) .ok-lead{animation:rise 600ms ease-out 500ms forwards}
467
+ .ok-phrase-row{
468
+ margin-top:14px;display:flex;align-items:center;justify-content:center;
469
+ opacity:0;
470
+ }
471
+ .panel:not([hidden]) .ok-phrase-row{animation:rise 600ms ease-out 620ms forwards}
472
+ .cmd.is-copied .cmd-quote-part{display:none}
473
+ .success-cta-wrap{
474
+ margin-top:48px;width:100%;opacity:0;
475
+ }
476
+ .panel:not([hidden]) .success-cta-wrap{animation:rise 600ms ease-out 780ms forwards}
477
+ a.btn-primary.success-cta{color:var(--btn-fg);text-decoration:none}
424
478
 
425
479
  .cmd{
426
480
  display:inline-flex;align-items:center;gap:8px;vertical-align:middle;
@@ -441,16 +495,6 @@ ${appLogoStyles}
441
495
  .cmd.is-copied .cmd-icon{color:var(--green);opacity:1}
442
496
  .cmd .cmd-icon svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
443
497
 
444
- .return-link{
445
- display:block;margin:36px auto 0;width:fit-content;
446
- font-family:var(--font-mono);font-size:11px;letter-spacing:0.18em;
447
- text-transform:uppercase;color:var(--fg4);text-decoration:none;
448
- border-bottom:1px dotted currentColor;padding-bottom:2px;
449
- opacity:0;transition:color 140ms;
450
- }
451
- .panel:not([hidden]) .return-link{animation:rise 600ms ease-out 760ms forwards}
452
- .return-link:hover{color:var(--fg2)}
453
-
454
498
  /* error specifics */
455
499
  .err-title{
456
500
  font-family:var(--font-display);font-weight:600;
@@ -514,7 +558,7 @@ ${appLogoStyles}
514
558
  }
515
559
  function providerDisplayName(clientName) {
516
560
  const name = (clientName ?? "").trim();
517
- if (!name) return "your agent";
561
+ if (!name) return "your assistant";
518
562
  return name.length > 40 ? name.slice(0, 40) + "\u2026" : name;
519
563
  }
520
564
  function successPanelInner(workspaceName, redirectUrl, providerName) {
@@ -536,18 +580,19 @@ function successPanelInner(workspaceName, redirectUrl, providerName) {
536
580
  <div class="orb-core"><div class="orb-dot"></div></div>
537
581
  </div>
538
582
  <div class="eyebrow success"><span class="dot"></span>Connected</div>
539
- <h1 class="ok-title">Product Brain is live.</h1>
540
- <p class="ok-sentence">
541
- <span>Then say</span>
542
- <button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy 'Start PB' and return to ${esc(providerName)}">
543
- <span data-cmd-text>Start PB</span>
583
+ <h1 class="ok-title">Product Brain is Live</h1>
584
+ <p class="ok-lead">Return to your assistant, then say</p>
585
+ <div class="ok-phrase-row">
586
+ <button class="cmd" type="button" data-cmd-pill data-redirect="${esc(redirectUrl)}" aria-label="Copy &quot;Start PB&quot; and return to ${esc(providerName)}">
587
+ <span class="cmd-quote-part" aria-hidden="true">&ldquo;</span><span data-cmd-text>Start PB</span><span class="cmd-quote-part" aria-hidden="true">&rdquo;</span>
544
588
  <span class="cmd-icon" aria-hidden="true">
545
589
  <svg data-cmd-svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/></svg>
546
590
  </span>
547
591
  </button>
548
- <span>in <span data-provider>${esc(providerName)}</span>.</span>
549
- </p>
550
- <a class="return-link" href="${esc(redirectUrl)}" data-return-link>Return to <span data-provider>${esc(providerName)}</span> &rarr;</a>
592
+ </div>
593
+ <div class="success-cta-wrap">
594
+ <a class="btn-primary success-cta" href="${esc(redirectUrl)}">Continue in ${esc(providerName)}</a>
595
+ </div>
551
596
  <!-- workspace name retained as data hook for tests, hidden from view -->
552
597
  <span hidden data-field="ws-name">${esc(workspaceName)}</span>`;
553
598
  }
@@ -579,7 +624,7 @@ var cmdScript = `
579
624
  pill.classList.add('is-copied');
580
625
  if(textEl)textEl.textContent='Copied';
581
626
  if(svgEl)svgEl.innerHTML='<polyline points="4 12 10 18 20 6"/>';
582
- setTimeout(function(){if(redirectUrl)window.location.href=redirectUrl},900);
627
+ setTimeout(function(){if(redirectUrl)window.location.assign(redirectUrl)},900);
583
628
  };
584
629
  try{
585
630
  if(navigator.clipboard&&navigator.clipboard.writeText){
@@ -677,7 +722,7 @@ ${cmdScript}
677
722
  function showSuccess(workspaceName,redirectUrl,providerName){
678
723
  var tpl=document.getElementById('tpl-connected');
679
724
  var safeWs=String(workspaceName||'').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
680
- var safeProv=String(providerName||'your agent').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
725
+ var safeProv=String(providerName||'your assistant').replace(/[<>&]/g,function(c){return{'<':'&lt;','>':'&gt;','&':'&amp;'}[c]});
681
726
  var safeUrl=String(redirectUrl||'').replace(/"/g,'&quot;').replace(/[<>]/g,function(c){return{'<':'&lt;','>':'&gt;'}[c]});
682
727
  var html=tpl.innerHTML.split('__WS__').join(safeWs).split('__URL__').join(safeUrl).split('__PROVIDER__').join(safeProv);
683
728
  pOk.innerHTML=html;
@@ -865,7 +910,7 @@ app.post(
865
910
  } catch {
866
911
  process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
867
912
  }
868
- const code = randomUUID();
913
+ const code = randomUUID2();
869
914
  pendingCodes.set(code, {
870
915
  apiKey: api_key,
871
916
  codeChallenge: code_challenge,
@@ -885,42 +930,13 @@ app.post(
885
930
  }
886
931
  );
887
932
  function issueTokens(apiKey) {
888
- const now = Date.now();
889
- const refreshToken = `pb_rt_${randomUUID()}`;
890
- let perKeyCount = 0;
891
- let oldestKeyForApiKey = null;
892
- let oldestAtForApiKey = Infinity;
893
- for (const [k, v] of refreshTokens) {
894
- if (v.apiKey === apiKey) {
895
- perKeyCount++;
896
- if (v.createdAt < oldestAtForApiKey) {
897
- oldestAtForApiKey = v.createdAt;
898
- oldestKeyForApiKey = k;
899
- }
900
- }
901
- }
902
- if (perKeyCount >= MAX_REFRESH_TOKENS_PER_KEY && oldestKeyForApiKey) {
903
- refreshTokens.delete(oldestKeyForApiKey);
904
- }
905
- if (refreshTokens.size >= MAX_REFRESH_TOKENS) {
906
- let oldestKey = null;
907
- let oldestAt = Infinity;
908
- for (const [k, v] of refreshTokens) {
909
- if (v.createdAt < oldestAt) {
910
- oldestAt = v.createdAt;
911
- oldestKey = k;
912
- }
913
- }
914
- if (oldestKey) refreshTokens.delete(oldestKey);
915
- }
916
- refreshTokens.set(refreshToken, { apiKey, createdAt: now });
917
933
  return {
918
934
  access_token: apiKey,
919
935
  token_type: "Bearer",
920
936
  // 1-year TTL: actual validity enforced by Convex, not by expiry clock.
921
937
  // Long TTL prevents unnecessary refresh cycles after restarts.
922
938
  expires_in: 365 * 24 * 3600,
923
- refresh_token: refreshToken
939
+ refresh_token: signRefreshToken(apiKey)
924
940
  };
925
941
  }
926
942
  app.post(
@@ -931,19 +947,15 @@ app.post(
931
947
  (req, res) => {
932
948
  const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
933
949
  if (grant_type === "refresh_token") {
934
- const entry = refreshTokens.get(refresh_token);
935
- if (!entry) {
936
- res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
937
- return;
938
- }
939
- if (Date.now() - entry.createdAt > REFRESH_TOKEN_TTL_MS) {
940
- refreshTokens.delete(refresh_token);
941
- res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
950
+ const verified = verifyRefreshToken(refresh_token);
951
+ if (!verified) {
952
+ res.status(400).json({
953
+ error: "invalid_grant",
954
+ error_description: "Invalid or expired refresh token"
955
+ });
942
956
  return;
943
957
  }
944
- const apiKey = entry.apiKey;
945
- refreshTokens.delete(refresh_token);
946
- res.json(issueTokens(apiKey));
958
+ res.json(issueTokens(verified.apiKey));
947
959
  return;
948
960
  }
949
961
  if (grant_type !== "authorization_code") {
@@ -1117,7 +1129,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1117
1129
  return;
1118
1130
  }
1119
1131
  const transport = new StreamableHTTPServerTransport({
1120
- sessionIdGenerator: () => randomUUID(),
1132
+ sessionIdGenerator: () => randomUUID2(),
1121
1133
  onsessioninitialized: (sid) => {
1122
1134
  sessions.set(sid, { transport, lastAccess: Date.now(), keyHash: keyH });
1123
1135
  logSessionLifecycle("session_created", sid);
@@ -1134,6 +1146,16 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
1134
1146
  await server.connect(transport);
1135
1147
  await transport.handleRequest(req, res, req.body);
1136
1148
  logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
1149
+ } else if (sessionId) {
1150
+ process.stderr.write(
1151
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale sessionId=${sessionId} (likely server restart \u2014 instructing client to re-initialise)
1152
+ `
1153
+ );
1154
+ res.status(404).json({
1155
+ jsonrpc: "2.0",
1156
+ error: { code: -32001, message: "Session not found \u2014 re-initialise" },
1157
+ id: null
1158
+ });
1137
1159
  } else {
1138
1160
  process.stderr.write(
1139
1161
  `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
@@ -1171,8 +1193,16 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
1171
1193
  return;
1172
1194
  }
1173
1195
  const sessionId = req.headers["mcp-session-id"];
1174
- if (!sessionId || !sessions.has(sessionId)) {
1175
- res.status(400).send("Invalid or missing session ID");
1196
+ if (!sessionId) {
1197
+ res.status(400).send("Missing Mcp-Session-Id header");
1198
+ return;
1199
+ }
1200
+ if (!sessions.has(sessionId)) {
1201
+ process.stderr.write(
1202
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale GET sessionId=${sessionId}
1203
+ `
1204
+ );
1205
+ res.status(404).send("Session not found \u2014 re-initialise");
1176
1206
  return;
1177
1207
  }
1178
1208
  try {
@@ -1208,8 +1238,16 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
1208
1238
  return;
1209
1239
  }
1210
1240
  const sessionId = req.headers["mcp-session-id"];
1211
- if (!sessionId || !sessions.has(sessionId)) {
1212
- res.status(400).send("Invalid or missing session ID");
1241
+ if (!sessionId) {
1242
+ res.status(400).send("Missing Mcp-Session-Id header");
1243
+ return;
1244
+ }
1245
+ if (!sessions.has(sessionId)) {
1246
+ process.stderr.write(
1247
+ `[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_stale DELETE sessionId=${sessionId}
1248
+ `
1249
+ );
1250
+ res.status(404).send("Session not found \u2014 re-initialise");
1213
1251
  return;
1214
1252
  }
1215
1253
  try {