@productbrain/mcp 0.0.1-beta.18 → 0.0.1-beta.181
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/.env.mcp.example +4 -0
- package/dist/chunk-XPFSARXP.js +15705 -0
- package/dist/chunk-XPFSARXP.js.map +1 -0
- package/dist/chunk-YMF3IQ5E.js +465 -0
- package/dist/chunk-YMF3IQ5E.js.map +1 -0
- package/dist/cli/index.js +1 -1
- package/dist/http.js +474 -61
- package/dist/http.js.map +1 -1
- package/dist/index.js +58 -37
- package/dist/index.js.map +1 -1
- package/dist/{setup-GZ5OZ5OP.js → setup-RYYXRDPB.js} +38 -106
- package/dist/setup-RYYXRDPB.js.map +1 -0
- package/dist/views/src/entry-cards/index.html +227 -0
- package/dist/views/src/graph-constellation/index.html +254 -0
- package/package.json +7 -3
- package/dist/chunk-47LO6K2R.js +0 -1423
- package/dist/chunk-47LO6K2R.js.map +0 -1
- package/dist/chunk-TUNNDDD7.js +0 -4558
- package/dist/chunk-TUNNDDD7.js.map +0 -1
- package/dist/chunk-XBMI6QHR.js +0 -100
- package/dist/chunk-XBMI6QHR.js.map +0 -1
- package/dist/setup-GZ5OZ5OP.js.map +0 -1
- package/dist/smart-capture-4DNBNMRG.js +0 -14
- package/dist/smart-capture-4DNBNMRG.js.map +0 -1
package/dist/http.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_CLOUD_URL,
|
|
2
3
|
SERVER_VERSION,
|
|
3
|
-
createProductBrainServer
|
|
4
|
-
} from "./chunk-TUNNDDD7.js";
|
|
5
|
-
import {
|
|
6
4
|
bootstrapHttp,
|
|
5
|
+
createProductBrainServer,
|
|
6
|
+
getKeyState,
|
|
7
|
+
hashKey,
|
|
8
|
+
initFeatureFlags,
|
|
7
9
|
runWithAuth
|
|
8
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-XPFSARXP.js";
|
|
9
11
|
import {
|
|
12
|
+
getPostHogClient,
|
|
10
13
|
initAnalytics,
|
|
11
14
|
shutdownAnalytics
|
|
12
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-YMF3IQ5E.js";
|
|
13
16
|
|
|
14
17
|
// src/http.ts
|
|
15
18
|
import { createHash, randomUUID } from "crypto";
|
|
@@ -19,19 +22,21 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
|
19
22
|
import rateLimit from "express-rate-limit";
|
|
20
23
|
bootstrapHttp();
|
|
21
24
|
initAnalytics();
|
|
22
|
-
|
|
25
|
+
initFeatureFlags(getPostHogClient());
|
|
26
|
+
var PORT = parseInt(process.env.PORT ?? process.env.MCP_PORT ?? "3002", 10);
|
|
23
27
|
function baseUrl(req) {
|
|
24
28
|
const proto = req.headers["x-forwarded-proto"] ?? req.protocol ?? "http";
|
|
25
29
|
const host = req.headers.host ?? `localhost:${PORT}`;
|
|
26
30
|
return `${proto}://${host}`;
|
|
27
31
|
}
|
|
28
32
|
var app = express();
|
|
33
|
+
app.set("trust proxy", 1);
|
|
29
34
|
app.use(express.json());
|
|
30
35
|
var ALLOWED_ORIGINS = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()).filter(Boolean);
|
|
31
36
|
app.use((_req, res, next) => {
|
|
32
37
|
const origin = _req.headers.origin;
|
|
33
|
-
if (
|
|
34
|
-
res.setHeader("Access-Control-Allow-Origin", origin
|
|
38
|
+
if (ALLOWED_ORIGINS && origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
39
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
35
40
|
}
|
|
36
41
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
37
42
|
res.setHeader(
|
|
@@ -62,17 +67,33 @@ app.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
|
62
67
|
token_endpoint: `${base}/oauth/token`,
|
|
63
68
|
registration_endpoint: `${base}/register`,
|
|
64
69
|
response_types_supported: ["code"],
|
|
65
|
-
grant_types_supported: ["authorization_code"],
|
|
70
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
66
71
|
code_challenge_methods_supported: ["S256"],
|
|
67
72
|
token_endpoint_auth_methods_supported: ["none"],
|
|
68
73
|
scopes_supported: ["mcp:tools", "mcp:resources"]
|
|
69
74
|
});
|
|
70
75
|
});
|
|
76
|
+
var authLimiter = rateLimit({
|
|
77
|
+
windowMs: 6e4,
|
|
78
|
+
max: 20,
|
|
79
|
+
standardHeaders: true,
|
|
80
|
+
legacyHeaders: false,
|
|
81
|
+
message: { error: "Too many auth requests. Try again later." }
|
|
82
|
+
});
|
|
71
83
|
var registeredClients = /* @__PURE__ */ new Map();
|
|
84
|
+
var MAX_REGISTERED_CLIENTS = 500;
|
|
72
85
|
app.post(
|
|
73
86
|
"/register",
|
|
87
|
+
authLimiter,
|
|
74
88
|
express.json(),
|
|
75
89
|
(req, res) => {
|
|
90
|
+
if (registeredClients.size >= MAX_REGISTERED_CLIENTS) {
|
|
91
|
+
res.status(503).json({
|
|
92
|
+
error: "server_error",
|
|
93
|
+
error_description: "Registration limit reached. Try again later."
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
76
97
|
const { redirect_uris, client_name } = req.body;
|
|
77
98
|
if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
|
78
99
|
res.status(400).json({
|
|
@@ -100,6 +121,13 @@ app.post(
|
|
|
100
121
|
}
|
|
101
122
|
);
|
|
102
123
|
var pendingCodes = /* @__PURE__ */ new Map();
|
|
124
|
+
var ACCESS_TOKEN_TTL = 3600;
|
|
125
|
+
var ACCESS_TOKEN_TTL_MS = ACCESS_TOKEN_TTL * 1e3;
|
|
126
|
+
var REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 6e4;
|
|
127
|
+
var refreshTokens = /* @__PURE__ */ new Map();
|
|
128
|
+
var MAX_REFRESH_TOKENS = 2e3;
|
|
129
|
+
var MAX_REFRESH_TOKENS_PER_KEY = 20;
|
|
130
|
+
var accessTokens = /* @__PURE__ */ new Map();
|
|
103
131
|
setInterval(() => {
|
|
104
132
|
const now = Date.now();
|
|
105
133
|
for (const [code, auth] of pendingCodes) {
|
|
@@ -108,63 +136,258 @@ setInterval(() => {
|
|
|
108
136
|
for (const [id, client] of registeredClients) {
|
|
109
137
|
if (now - client.registeredAt > 24 * 60 * 6e4) registeredClients.delete(id);
|
|
110
138
|
}
|
|
139
|
+
for (const [token, entry] of refreshTokens) {
|
|
140
|
+
if (now - entry.createdAt > REFRESH_TOKEN_TTL_MS) refreshTokens.delete(token);
|
|
141
|
+
}
|
|
142
|
+
if (refreshTokens.size > MAX_REFRESH_TOKENS) {
|
|
143
|
+
const sorted = [...refreshTokens.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
144
|
+
for (let i = 0; i < sorted.length - MAX_REFRESH_TOKENS; i++) {
|
|
145
|
+
refreshTokens.delete(sorted[i][0]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const [token, entry] of accessTokens) {
|
|
149
|
+
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) accessTokens.delete(token);
|
|
150
|
+
}
|
|
151
|
+
for (const [ip, rec] of authFailures) {
|
|
152
|
+
if (rec.blockedUntil < now && rec.firstFailure + AUTH_FAILURE_WINDOW_MS < now) {
|
|
153
|
+
authFailures.delete(ip);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (authFailures.size > MAX_AUTH_FAILURE_ENTRIES) {
|
|
157
|
+
const sorted = [...authFailures.entries()].sort((a, b) => a[1].firstFailure - b[1].firstFailure);
|
|
158
|
+
for (let i = 0; i < sorted.length - MAX_AUTH_FAILURE_ENTRIES; i++) {
|
|
159
|
+
authFailures.delete(sorted[i][0]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
111
162
|
}, 6e4);
|
|
112
163
|
function esc(s) {
|
|
113
164
|
return String(s ?? "").replace(
|
|
114
|
-
/[&"<>]/g,
|
|
115
|
-
(c) => ({ "&": "&", '"': """, "<": "<", ">": ">" })[c]
|
|
165
|
+
/[&"'<>]/g,
|
|
166
|
+
(c) => ({ "&": "&", '"': """, "'": "'", "<": "<", ">": ">" })[c]
|
|
116
167
|
);
|
|
117
168
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
res.type("html").send(`<!DOCTYPE html>
|
|
169
|
+
function authPageShell(title, bodyContent, headExtra = "") {
|
|
170
|
+
return `<!DOCTYPE html>
|
|
121
171
|
<html lang="en"><head>
|
|
122
172
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
123
|
-
<title
|
|
173
|
+
<title>${title} \u2014 Product Brain</title>
|
|
174
|
+
${headExtra}
|
|
124
175
|
<style>
|
|
176
|
+
:root{--bg:#0d0c10;--surface:#18161e;--border:#2d2840;--border-focus:#7c3aed;--text:#e8e4f0;--muted:#7b7590;--accent:#7c3aed;--accent-h:#6d28d9;--err:#f87171;--ok:#34d399}
|
|
125
177
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
126
|
-
body{font-family:-apple-system,system-ui,sans-serif;background
|
|
127
|
-
|
|
128
|
-
.card{background
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
178
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem}
|
|
179
|
+
body::before{content:'';position:fixed;inset:0;background-image:radial-gradient(circle,#3d3560 1px,transparent 1px);background-size:28px 28px;opacity:.22;pointer-events:none;z-index:0}
|
|
180
|
+
.card{position:relative;z-index:1;background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:2rem 2rem 1.75rem;max-width:380px;width:100%}
|
|
181
|
+
</style>
|
|
182
|
+
</head><body>
|
|
183
|
+
<div class="card">${bodyContent}</div>
|
|
184
|
+
</body></html>`;
|
|
185
|
+
}
|
|
186
|
+
function authorizeFormPage(params) {
|
|
187
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = params;
|
|
188
|
+
const body = `
|
|
189
|
+
<style>
|
|
190
|
+
.brand{display:flex;align-items:center;gap:.55rem;margin-bottom:1.5rem}
|
|
191
|
+
.brand-icon{width:30px;height:30px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:1rem;flex-shrink:0}
|
|
192
|
+
.brand-name{font-size:.88rem;font-weight:600;letter-spacing:-.01em}
|
|
193
|
+
h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.35rem;line-height:1.3}
|
|
194
|
+
.sub{font-size:.8rem;color:var(--muted);margin-bottom:1.75rem;line-height:1.55}
|
|
195
|
+
label{display:block;font-size:.72rem;font-weight:600;color:#a89fc4;margin-bottom:.4rem;letter-spacing:.04em;text-transform:uppercase}
|
|
196
|
+
.inp-wrap{position:relative}
|
|
197
|
+
input[type=password]{width:100%;padding:.65rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font:.875rem/1.4 'JetBrains Mono','Fira Code',ui-monospace,monospace;outline:none;transition:border-color .15s,box-shadow .15s}
|
|
198
|
+
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,58,237,.15)}
|
|
199
|
+
input.err{border-color:var(--err);box-shadow:0 0 0 3px rgba(248,113,113,.12)}
|
|
200
|
+
.field-row{display:flex;align-items:center;justify-content:space-between;margin-top:.4rem}
|
|
201
|
+
.hint{font-size:.73rem;color:var(--muted)}
|
|
202
|
+
.hint code{font-size:.72rem;background:rgba(255,255,255,.06);padding:.1rem .3rem;border-radius:4px}
|
|
203
|
+
.get-key{font-size:.73rem;color:var(--accent);text-decoration:none;opacity:.85;transition:opacity .1s}
|
|
204
|
+
.get-key:hover{opacity:1;text-decoration:underline}
|
|
205
|
+
.err-msg{font-size:.73rem;color:var(--err);margin-top:.35rem;display:none}
|
|
206
|
+
.err-msg.show{display:block}
|
|
207
|
+
.btn{width:100%;padding:.7rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.88rem;font-weight:600;cursor:pointer;margin-top:1.25rem;letter-spacing:-.01em;transition:background .15s,transform .1s;display:flex;align-items:center;justify-content:center;gap:.5rem}
|
|
208
|
+
.btn:hover{background:var(--accent-h)}
|
|
209
|
+
.btn:active{transform:scale(.98)}
|
|
210
|
+
.btn:disabled{opacity:.6;cursor:not-allowed;transform:none}
|
|
211
|
+
.spinner{width:15px;height:15px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;display:none}
|
|
212
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
213
|
+
.divider{height:1px;background:var(--border);margin:1.5rem -2rem}
|
|
214
|
+
.footer{font-size:.7rem;color:var(--muted);text-align:center;padding-top:.5rem;line-height:1.6}
|
|
215
|
+
.footer a{color:var(--muted);text-decoration:underline}
|
|
216
|
+
</style>
|
|
217
|
+
<div class="brand"><div class="brand-icon">⬡</div><span class="brand-name">Product Brain</span></div>
|
|
218
|
+
<h1>Connect to Claude</h1>
|
|
219
|
+
<p class="sub">Enter your API key to give Claude access to your workspace.</p>
|
|
220
|
+
<form method="POST" action="/authorize" id="f">
|
|
144
221
|
<input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
|
|
145
222
|
<input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
|
|
146
223
|
<input type="hidden" name="code_challenge_method" value="${esc(code_challenge_method)}">
|
|
147
224
|
<input type="hidden" name="state" value="${esc(state)}">
|
|
225
|
+
<input type="hidden" name="client_id" value="${esc(client_id)}">
|
|
148
226
|
<label for="k">API Key</label>
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
227
|
+
<div class="inp-wrap">
|
|
228
|
+
<input type="password" id="k" name="api_key" placeholder="pb_sk_\u2026" required autofocus autocomplete="off">
|
|
229
|
+
</div>
|
|
230
|
+
<div class="field-row">
|
|
231
|
+
<span class="hint">Starts with <code>pb_sk_</code></span>
|
|
232
|
+
<a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="get-key">No key? Get one →</a>
|
|
233
|
+
</div>
|
|
234
|
+
<p class="err-msg" id="em">Key must start with <code>pb_sk_</code></p>
|
|
235
|
+
<button type="submit" class="btn" id="sb">
|
|
236
|
+
<span id="bt">Authorize</span>
|
|
237
|
+
<div class="spinner" id="sp"></div>
|
|
238
|
+
</button>
|
|
152
239
|
</form>
|
|
240
|
+
<div class="divider"></div>
|
|
241
|
+
<p class="footer">Product Brain gives Claude access to your knowledge graph.<br><a href="https://productbrain.io" target="_blank" rel="noopener noreferrer">Learn more</a></p>
|
|
242
|
+
<script>
|
|
243
|
+
var f=document.getElementById('f'),k=document.getElementById('k'),em=document.getElementById('em'),sb=document.getElementById('sb'),bt=document.getElementById('bt'),sp=document.getElementById('sp');
|
|
244
|
+
k.addEventListener('input',function(){var v=k.value.trim();if(v&&!v.startsWith('pb_sk_')){k.classList.add('err');em.classList.add('show')}else{k.classList.remove('err');em.classList.remove('show')}});
|
|
245
|
+
f.addEventListener('submit',function(e){var v=k.value.trim();if(!v.startsWith('pb_sk_')){e.preventDefault();k.classList.add('err');em.classList.add('show');k.focus();return}sb.disabled=true;bt.textContent='Verifying\u2026';sp.style.display='block'});
|
|
246
|
+
</script>`;
|
|
247
|
+
return authPageShell("Connect to Claude", body);
|
|
248
|
+
}
|
|
249
|
+
function authorizeSuccessPage(workspaceName, redirectUrl) {
|
|
250
|
+
const safeUrl = JSON.stringify(redirectUrl).replace(/<\/script>/gi, "<\\/script>");
|
|
251
|
+
const body = `
|
|
252
|
+
<style>
|
|
253
|
+
.card{text-align:center;padding:2.5rem 2rem}
|
|
254
|
+
.ring{width:60px;height:60px;background:rgba(52,211,153,.1);border:1px solid rgba(52,211,153,.25);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 1.4rem;animation:pop .5s cubic-bezier(.175,.885,.32,1.275) .15s both}
|
|
255
|
+
@keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
|
|
256
|
+
svg{animation:draw .6s ease .5s both}
|
|
257
|
+
@keyframes draw{from{stroke-dashoffset:30}to{stroke-dashoffset:0}}
|
|
258
|
+
h1{font-size:1.3rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.3rem}
|
|
259
|
+
.ws{font-size:.8rem;color:var(--ok);font-weight:500;margin-bottom:1.5rem}
|
|
260
|
+
.redirect-msg{font-size:.78rem;color:var(--muted);margin-bottom:1.4rem}
|
|
261
|
+
.dots::after{content:'';animation:dots 1.4s steps(4,end) infinite}
|
|
262
|
+
@keyframes dots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
|
|
263
|
+
.continue{display:inline-flex;align-items:center;gap:.4rem;padding:.6rem 1.2rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.84rem;font-weight:600;cursor:pointer;text-decoration:none;transition:background .15s}
|
|
264
|
+
.continue:hover{background:var(--accent-h)}
|
|
265
|
+
@keyframes fadein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
|
266
|
+
.card{animation:fadein .35s ease}
|
|
267
|
+
</style>
|
|
268
|
+
<div class="ring">
|
|
269
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="30"><polyline points="20 6 9 17 4 12"/></svg>
|
|
153
270
|
</div>
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
271
|
+
<h1>Claude is connected</h1>
|
|
272
|
+
<p class="ws">${esc(workspaceName)}</p>
|
|
273
|
+
<p class="redirect-msg">Returning to Claude<span class="dots"></span></p>
|
|
274
|
+
<a href="${esc(redirectUrl)}" class="continue">Continue to Claude →</a>
|
|
275
|
+
<script>setTimeout(function(){window.location.href=${safeUrl}},1800)</script>`;
|
|
276
|
+
return authPageShell("Connected", body);
|
|
277
|
+
}
|
|
278
|
+
function authorizeErrorPage(title, trustedDetailHtml, retryUrl) {
|
|
279
|
+
const body = `
|
|
280
|
+
<style>
|
|
281
|
+
.card{text-align:center;padding:2.25rem 2rem}
|
|
282
|
+
.x-ring{width:52px;height:52px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 1.25rem;font-size:1.4rem;animation:shake .4s ease .1s}
|
|
283
|
+
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}
|
|
284
|
+
h1{font-size:1.2rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem;color:var(--err)}
|
|
285
|
+
.detail{font-size:.8rem;color:var(--muted);line-height:1.6;margin-bottom:1.5rem}
|
|
286
|
+
.detail code{font-size:.75rem;background:rgba(255,255,255,.06);padding:.1rem .3rem;border-radius:4px}
|
|
287
|
+
.actions{display:flex;gap:.75rem;justify-content:center;flex-wrap:wrap}
|
|
288
|
+
.btn-retry{display:inline-flex;align-items:center;padding:.55rem 1.1rem;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:.82rem;font-weight:600;cursor:pointer;text-decoration:none;transition:background .15s}
|
|
289
|
+
.btn-retry:hover{background:var(--accent-h)}
|
|
290
|
+
.btn-key{display:inline-flex;align-items:center;padding:.55rem 1rem;background:transparent;color:var(--muted);border:1px solid var(--border);border-radius:10px;font-size:.82rem;text-decoration:none;transition:border-color .15s,color .15s}
|
|
291
|
+
.btn-key:hover{border-color:var(--accent);color:var(--text)}
|
|
292
|
+
</style>
|
|
293
|
+
<div class="x-ring">✕</div>
|
|
294
|
+
<h1>${esc(title)}</h1>
|
|
295
|
+
<p class="detail">${trustedDetailHtml}</p>
|
|
296
|
+
<div class="actions">
|
|
297
|
+
<a href="${esc(retryUrl)}" class="btn-retry">← Try again</a>
|
|
298
|
+
<a href="https://productbrain.io" target="_blank" rel="noopener noreferrer" class="btn-key">Get an API key →</a>
|
|
299
|
+
</div>`;
|
|
300
|
+
return authPageShell("Connection error", body);
|
|
301
|
+
}
|
|
302
|
+
app.get("/authorize", authLimiter, (req, res) => {
|
|
303
|
+
const { redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.query;
|
|
304
|
+
res.type("html").send(authorizeFormPage({
|
|
305
|
+
redirect_uri: String(redirect_uri ?? ""),
|
|
306
|
+
code_challenge: String(code_challenge ?? ""),
|
|
307
|
+
code_challenge_method: String(code_challenge_method ?? "S256"),
|
|
308
|
+
state: String(state ?? ""),
|
|
309
|
+
client_id: String(client_id ?? "")
|
|
310
|
+
}));
|
|
158
311
|
});
|
|
159
312
|
app.post(
|
|
160
313
|
"/authorize",
|
|
314
|
+
authLimiter,
|
|
161
315
|
express.urlencoded({ extended: false }),
|
|
162
|
-
(req, res) => {
|
|
163
|
-
const { api_key, redirect_uri, code_challenge, state } = req.body;
|
|
316
|
+
async (req, res) => {
|
|
317
|
+
const { api_key, redirect_uri, code_challenge, code_challenge_method, state, client_id } = req.body;
|
|
318
|
+
const retryParams = new URLSearchParams({
|
|
319
|
+
redirect_uri: redirect_uri ?? "",
|
|
320
|
+
code_challenge: code_challenge ?? "",
|
|
321
|
+
code_challenge_method: code_challenge_method ?? "S256",
|
|
322
|
+
...state ? { state } : {},
|
|
323
|
+
...client_id ? { client_id } : {}
|
|
324
|
+
}).toString();
|
|
325
|
+
const retryUrl = `/authorize?${retryParams}`;
|
|
164
326
|
if (!api_key?.startsWith("pb_sk_")) {
|
|
165
|
-
res.
|
|
327
|
+
res.type("html").send(authorizeErrorPage(
|
|
328
|
+
"Invalid key format",
|
|
329
|
+
"API keys start with <code>pb_sk_</code>. Check your key and try again.",
|
|
330
|
+
retryUrl
|
|
331
|
+
));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!client_id || !registeredClients.has(client_id)) {
|
|
335
|
+
res.status(400).json({
|
|
336
|
+
error: "invalid_request",
|
|
337
|
+
error_description: "Unknown or missing client_id"
|
|
338
|
+
});
|
|
166
339
|
return;
|
|
167
340
|
}
|
|
341
|
+
const client = registeredClients.get(client_id);
|
|
342
|
+
if (!client.redirect_uris.includes(redirect_uri)) {
|
|
343
|
+
res.status(400).json({
|
|
344
|
+
error: "invalid_request",
|
|
345
|
+
error_description: "redirect_uri does not match any registered redirect for this client"
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
let workspaceName = "Your Workspace";
|
|
350
|
+
try {
|
|
351
|
+
const primaryUrl = (process.env.CONVEX_SITE_URL ?? DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
352
|
+
const fallbackUrls = (process.env.CONVEX_FALLBACK_URLS ?? "").split(",").map((u) => u.trim().replace(/\/$/, "")).filter(Boolean);
|
|
353
|
+
const candidates = [primaryUrl, ...fallbackUrls];
|
|
354
|
+
let foundUrl;
|
|
355
|
+
let anyDefinitiveReject = false;
|
|
356
|
+
for (const url2 of candidates) {
|
|
357
|
+
let checkData = null;
|
|
358
|
+
try {
|
|
359
|
+
const checkRes = await fetch(`${url2}/api/key-check`, {
|
|
360
|
+
method: "POST",
|
|
361
|
+
headers: { "Authorization": `Bearer ${api_key}`, "Content-Type": "application/json" },
|
|
362
|
+
signal: AbortSignal.timeout(5e3)
|
|
363
|
+
});
|
|
364
|
+
checkData = await checkRes.json();
|
|
365
|
+
} catch {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (checkData.ok) {
|
|
369
|
+
if (checkData.workspaceName) workspaceName = checkData.workspaceName;
|
|
370
|
+
foundUrl = checkData.deploymentUrl ?? url2;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
anyDefinitiveReject = true;
|
|
374
|
+
}
|
|
375
|
+
if (!foundUrl) {
|
|
376
|
+
if (anyDefinitiveReject) {
|
|
377
|
+
res.type("html").send(authorizeErrorPage(
|
|
378
|
+
"Key not recognized",
|
|
379
|
+
"This API key wasn't found in Product Brain. Check your API Keys in Studio and try again.",
|
|
380
|
+
retryUrl
|
|
381
|
+
));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
385
|
+
} else {
|
|
386
|
+
getKeyState(api_key).deploymentUrl = foundUrl;
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
process.stderr.write("[authorize] key-check unavailable \u2014 proceeding without validation\n");
|
|
390
|
+
}
|
|
168
391
|
const code = randomUUID();
|
|
169
392
|
pendingCodes.set(code, {
|
|
170
393
|
apiKey: api_key,
|
|
@@ -175,15 +398,72 @@ app.post(
|
|
|
175
398
|
const url = new URL(redirect_uri);
|
|
176
399
|
url.searchParams.set("code", code);
|
|
177
400
|
if (state) url.searchParams.set("state", state);
|
|
178
|
-
|
|
401
|
+
const redirectUrl = url.toString();
|
|
402
|
+
res.type("html").send(authorizeSuccessPage(workspaceName, redirectUrl));
|
|
179
403
|
}
|
|
180
404
|
);
|
|
405
|
+
function issueTokens(apiKey) {
|
|
406
|
+
const now = Date.now();
|
|
407
|
+
const refreshToken = `pb_rt_${randomUUID()}`;
|
|
408
|
+
let perKeyCount = 0;
|
|
409
|
+
let oldestKeyForApiKey = null;
|
|
410
|
+
let oldestAtForApiKey = Infinity;
|
|
411
|
+
for (const [k, v] of refreshTokens) {
|
|
412
|
+
if (v.apiKey === apiKey) {
|
|
413
|
+
perKeyCount++;
|
|
414
|
+
if (v.createdAt < oldestAtForApiKey) {
|
|
415
|
+
oldestAtForApiKey = v.createdAt;
|
|
416
|
+
oldestKeyForApiKey = k;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (perKeyCount >= MAX_REFRESH_TOKENS_PER_KEY && oldestKeyForApiKey) {
|
|
421
|
+
refreshTokens.delete(oldestKeyForApiKey);
|
|
422
|
+
}
|
|
423
|
+
if (refreshTokens.size >= MAX_REFRESH_TOKENS) {
|
|
424
|
+
let oldestKey = null;
|
|
425
|
+
let oldestAt = Infinity;
|
|
426
|
+
for (const [k, v] of refreshTokens) {
|
|
427
|
+
if (v.createdAt < oldestAt) {
|
|
428
|
+
oldestAt = v.createdAt;
|
|
429
|
+
oldestKey = k;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (oldestKey) refreshTokens.delete(oldestKey);
|
|
433
|
+
}
|
|
434
|
+
refreshTokens.set(refreshToken, { apiKey, createdAt: now });
|
|
435
|
+
return {
|
|
436
|
+
access_token: apiKey,
|
|
437
|
+
token_type: "Bearer",
|
|
438
|
+
// 1-year TTL: actual validity enforced by Convex, not by expiry clock.
|
|
439
|
+
// Long TTL prevents unnecessary refresh cycles after restarts.
|
|
440
|
+
expires_in: 365 * 24 * 3600,
|
|
441
|
+
refresh_token: refreshToken
|
|
442
|
+
};
|
|
443
|
+
}
|
|
181
444
|
app.post(
|
|
182
445
|
"/oauth/token",
|
|
446
|
+
authLimiter,
|
|
183
447
|
express.urlencoded({ extended: false }),
|
|
184
448
|
express.json(),
|
|
185
449
|
(req, res) => {
|
|
186
|
-
const { grant_type, code, code_verifier, redirect_uri } = req.body;
|
|
450
|
+
const { grant_type, code, code_verifier, redirect_uri, refresh_token } = req.body;
|
|
451
|
+
if (grant_type === "refresh_token") {
|
|
452
|
+
const entry = refreshTokens.get(refresh_token);
|
|
453
|
+
if (!entry) {
|
|
454
|
+
res.status(400).json({ error: "invalid_grant", error_description: "Invalid refresh token" });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (Date.now() - entry.createdAt > REFRESH_TOKEN_TTL_MS) {
|
|
458
|
+
refreshTokens.delete(refresh_token);
|
|
459
|
+
res.status(400).json({ error: "invalid_grant", error_description: "Refresh token expired" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const apiKey = entry.apiKey;
|
|
463
|
+
refreshTokens.delete(refresh_token);
|
|
464
|
+
res.json(issueTokens(apiKey));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
187
467
|
if (grant_type !== "authorization_code") {
|
|
188
468
|
res.status(400).json({ error: "unsupported_grant_type" });
|
|
189
469
|
return;
|
|
@@ -203,11 +483,7 @@ app.post(
|
|
|
203
483
|
return;
|
|
204
484
|
}
|
|
205
485
|
pendingCodes.delete(code);
|
|
206
|
-
res.json(
|
|
207
|
-
access_token: pending.apiKey,
|
|
208
|
-
token_type: "Bearer",
|
|
209
|
-
expires_in: 3600
|
|
210
|
-
});
|
|
486
|
+
res.json(issueTokens(pending.apiKey));
|
|
211
487
|
}
|
|
212
488
|
);
|
|
213
489
|
var mcpLimiter = rateLimit({
|
|
@@ -217,16 +493,46 @@ var mcpLimiter = rateLimit({
|
|
|
217
493
|
legacyHeaders: false,
|
|
218
494
|
message: { error: "Too many requests. Try again later." }
|
|
219
495
|
});
|
|
496
|
+
var authFailures = /* @__PURE__ */ new Map();
|
|
497
|
+
var AUTH_FAILURE_MAX = 10;
|
|
498
|
+
var AUTH_FAILURE_WINDOW_MS = 5 * 6e4;
|
|
499
|
+
var AUTH_BLOCK_DURATION_MS = 15 * 6e4;
|
|
500
|
+
var MAX_AUTH_FAILURE_ENTRIES = 1e4;
|
|
501
|
+
function checkAuthBlock(ip) {
|
|
502
|
+
const rec = authFailures.get(ip);
|
|
503
|
+
if (!rec) return false;
|
|
504
|
+
return rec.blockedUntil > Date.now();
|
|
505
|
+
}
|
|
506
|
+
function recordAuthFailure(ip) {
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
const rec = authFailures.get(ip);
|
|
509
|
+
if (!rec) {
|
|
510
|
+
authFailures.set(ip, { count: 1, firstFailure: now, blockedUntil: 0 });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (now - rec.firstFailure > AUTH_FAILURE_WINDOW_MS) {
|
|
514
|
+
rec.count = 1;
|
|
515
|
+
rec.firstFailure = now;
|
|
516
|
+
rec.blockedUntil = 0;
|
|
517
|
+
} else {
|
|
518
|
+
rec.count++;
|
|
519
|
+
if (rec.count >= AUTH_FAILURE_MAX) {
|
|
520
|
+
rec.blockedUntil = now + AUTH_BLOCK_DURATION_MS;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
220
524
|
app.get("/health", (_req, res) => {
|
|
221
525
|
res.json({ status: "ok", version: SERVER_VERSION, transport: "http" });
|
|
222
526
|
});
|
|
223
527
|
var sessions = /* @__PURE__ */ new Map();
|
|
224
528
|
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
225
529
|
var MAX_SESSIONS = 200;
|
|
530
|
+
var MAX_SESSIONS_PER_KEY = 5;
|
|
226
531
|
function evictStaleSessions() {
|
|
227
532
|
const now = Date.now();
|
|
228
533
|
for (const [id, entry] of sessions) {
|
|
229
534
|
if (now - entry.lastAccess > SESSION_TTL_MS) {
|
|
535
|
+
logSessionLifecycle("session_deleted", id, "ttl");
|
|
230
536
|
entry.transport.close().catch(() => {
|
|
231
537
|
});
|
|
232
538
|
sessions.delete(id);
|
|
@@ -237,6 +543,7 @@ function evictStaleSessions() {
|
|
|
237
543
|
(a, b) => a[1].lastAccess - b[1].lastAccess
|
|
238
544
|
);
|
|
239
545
|
for (let i = 0; i < sorted.length - MAX_SESSIONS; i++) {
|
|
546
|
+
logSessionLifecycle("session_deleted", sorted[i][0], "eviction");
|
|
240
547
|
sorted[i][1].transport.close().catch(() => {
|
|
241
548
|
});
|
|
242
549
|
sessions.delete(sorted[i][0]);
|
|
@@ -248,7 +555,20 @@ function extractBearerKey(req) {
|
|
|
248
555
|
const header = req.headers?.authorization;
|
|
249
556
|
if (typeof header !== "string" || !header.startsWith("Bearer ")) return null;
|
|
250
557
|
const token = header.slice(7).trim();
|
|
251
|
-
|
|
558
|
+
if (token.startsWith("pb_sk_")) {
|
|
559
|
+
return token;
|
|
560
|
+
}
|
|
561
|
+
if (token.startsWith("pb_at_")) {
|
|
562
|
+
const entry = accessTokens.get(token);
|
|
563
|
+
if (!entry) return null;
|
|
564
|
+
const now = Date.now();
|
|
565
|
+
if (now - entry.createdAt > ACCESS_TOKEN_TTL_MS) {
|
|
566
|
+
accessTokens.delete(token);
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
return entry.apiKey;
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
252
572
|
}
|
|
253
573
|
function send401(req, res) {
|
|
254
574
|
const base = baseUrl(req);
|
|
@@ -257,43 +577,86 @@ function send401(req, res) {
|
|
|
257
577
|
`Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`
|
|
258
578
|
).json({ error: "unauthorized" });
|
|
259
579
|
}
|
|
260
|
-
function logRequest(method, outcome, sessionId) {
|
|
580
|
+
function logRequest(method, outcome, sessionId, durationMs) {
|
|
261
581
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
262
582
|
const sid = sessionId ? ` session=${sessionId}` : "";
|
|
263
|
-
|
|
583
|
+
const dur = durationMs != null ? ` duration=${durationMs}ms` : "";
|
|
584
|
+
process.stderr.write(`[HTTP] ${ts} ${method} ${outcome}${sid}${dur}
|
|
585
|
+
`);
|
|
586
|
+
}
|
|
587
|
+
function logSessionLifecycle(event, sessionId, reason) {
|
|
588
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
589
|
+
const r = reason ? ` reason=${reason}` : "";
|
|
590
|
+
process.stderr.write(`[HTTP] ${ts} ${event} session=${sessionId}${r}
|
|
264
591
|
`);
|
|
265
592
|
}
|
|
266
593
|
app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
594
|
+
const reqIp = req.ip ?? "unknown";
|
|
595
|
+
if (checkAuthBlock(reqIp)) {
|
|
596
|
+
res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
267
599
|
const apiKey = extractBearerKey(req);
|
|
268
600
|
if (!apiKey) {
|
|
269
601
|
logRequest("POST", "auth_fail");
|
|
602
|
+
recordAuthFailure(reqIp);
|
|
270
603
|
send401(req, res);
|
|
271
604
|
return;
|
|
272
605
|
}
|
|
273
606
|
const sessionId = req.headers["mcp-session-id"];
|
|
607
|
+
const reqStart = Date.now();
|
|
274
608
|
try {
|
|
275
609
|
await runWithAuth({ apiKey }, async () => {
|
|
276
610
|
if (sessionId && sessions.has(sessionId)) {
|
|
277
611
|
const entry = sessions.get(sessionId);
|
|
612
|
+
if (entry.keyHash !== hashKey(apiKey)) {
|
|
613
|
+
res.status(403).json({
|
|
614
|
+
jsonrpc: "2.0",
|
|
615
|
+
error: { code: -32e3, message: "Session key mismatch" },
|
|
616
|
+
id: null
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
278
620
|
entry.lastAccess = Date.now();
|
|
279
621
|
await entry.transport.handleRequest(req, res, req.body);
|
|
280
|
-
logRequest("POST", "ok", sessionId);
|
|
622
|
+
logRequest("POST", "ok", sessionId, Date.now() - reqStart);
|
|
281
623
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
624
|
+
const keyH = hashKey(apiKey);
|
|
625
|
+
let keySessionCount = 0;
|
|
626
|
+
for (const entry of sessions.values()) {
|
|
627
|
+
if (entry.keyHash === keyH) keySessionCount++;
|
|
628
|
+
}
|
|
629
|
+
if (keySessionCount >= MAX_SESSIONS_PER_KEY) {
|
|
630
|
+
res.status(429).json({
|
|
631
|
+
jsonrpc: "2.0",
|
|
632
|
+
error: { code: -32e3, message: "Too many sessions for this API key" },
|
|
633
|
+
id: null
|
|
634
|
+
});
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
282
637
|
const transport = new StreamableHTTPServerTransport({
|
|
283
638
|
sessionIdGenerator: () => randomUUID(),
|
|
284
639
|
onsessioninitialized: (sid) => {
|
|
285
|
-
sessions.set(sid, { transport, lastAccess: Date.now() });
|
|
286
|
-
|
|
640
|
+
sessions.set(sid, { transport, lastAccess: Date.now(), keyHash: keyH });
|
|
641
|
+
logSessionLifecycle("session_created", sid);
|
|
287
642
|
}
|
|
288
643
|
});
|
|
289
644
|
transport.onclose = () => {
|
|
290
645
|
const sid = transport.sessionId;
|
|
291
|
-
if (sid)
|
|
646
|
+
if (sid) {
|
|
647
|
+
logSessionLifecycle("session_deleted", sid, "onclose");
|
|
648
|
+
sessions.delete(sid);
|
|
649
|
+
}
|
|
292
650
|
};
|
|
293
651
|
const server = createProductBrainServer();
|
|
294
652
|
await server.connect(transport);
|
|
295
653
|
await transport.handleRequest(req, res, req.body);
|
|
654
|
+
logRequest("POST", "ok", transport.sessionId ?? void 0, Date.now() - reqStart);
|
|
296
655
|
} else {
|
|
656
|
+
process.stderr.write(
|
|
657
|
+
`[HTTP] ${(/* @__PURE__ */ new Date()).toISOString()} session_invalid no valid session ID (client may have omitted Mcp-Session-Id)
|
|
658
|
+
`
|
|
659
|
+
);
|
|
297
660
|
res.status(400).json({
|
|
298
661
|
jsonrpc: "2.0",
|
|
299
662
|
error: { code: -32e3, message: "Bad Request: no valid session ID provided" },
|
|
@@ -302,7 +665,7 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
302
665
|
}
|
|
303
666
|
});
|
|
304
667
|
} catch (err) {
|
|
305
|
-
logRequest("POST", "error", sessionId);
|
|
668
|
+
logRequest("POST", "error", sessionId, Date.now() - reqStart);
|
|
306
669
|
if (!res.headersSent) {
|
|
307
670
|
res.status(500).json({
|
|
308
671
|
jsonrpc: "2.0",
|
|
@@ -313,9 +676,15 @@ app.post("/mcp", mcpLimiter, async (req, res) => {
|
|
|
313
676
|
}
|
|
314
677
|
});
|
|
315
678
|
app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
679
|
+
const reqIp = req.ip ?? "unknown";
|
|
680
|
+
if (checkAuthBlock(reqIp)) {
|
|
681
|
+
res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
316
684
|
const apiKey = extractBearerKey(req);
|
|
317
685
|
if (!apiKey) {
|
|
318
686
|
logRequest("GET", "auth_fail");
|
|
687
|
+
recordAuthFailure(reqIp);
|
|
319
688
|
send401(req, res);
|
|
320
689
|
return;
|
|
321
690
|
}
|
|
@@ -327,6 +696,14 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
327
696
|
try {
|
|
328
697
|
await runWithAuth({ apiKey }, async () => {
|
|
329
698
|
const entry = sessions.get(sessionId);
|
|
699
|
+
if (entry.keyHash !== hashKey(apiKey)) {
|
|
700
|
+
res.status(403).json({
|
|
701
|
+
jsonrpc: "2.0",
|
|
702
|
+
error: { code: -32e3, message: "Session key mismatch" },
|
|
703
|
+
id: null
|
|
704
|
+
});
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
330
707
|
entry.lastAccess = Date.now();
|
|
331
708
|
await entry.transport.handleRequest(req, res);
|
|
332
709
|
logRequest("GET", "ok", sessionId);
|
|
@@ -336,9 +713,15 @@ app.get("/mcp", mcpLimiter, async (req, res) => {
|
|
|
336
713
|
}
|
|
337
714
|
});
|
|
338
715
|
app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
716
|
+
const reqIp = req.ip ?? "unknown";
|
|
717
|
+
if (checkAuthBlock(reqIp)) {
|
|
718
|
+
res.status(429).json({ error: "Too many failed auth attempts. Try again later." });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
339
721
|
const apiKey = extractBearerKey(req);
|
|
340
722
|
if (!apiKey) {
|
|
341
723
|
logRequest("DELETE", "auth_fail");
|
|
724
|
+
recordAuthFailure(reqIp);
|
|
342
725
|
send401(req, res);
|
|
343
726
|
return;
|
|
344
727
|
}
|
|
@@ -350,6 +733,14 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
350
733
|
try {
|
|
351
734
|
await runWithAuth({ apiKey }, async () => {
|
|
352
735
|
const entry = sessions.get(sessionId);
|
|
736
|
+
if (entry.keyHash !== hashKey(apiKey)) {
|
|
737
|
+
res.status(403).json({
|
|
738
|
+
jsonrpc: "2.0",
|
|
739
|
+
error: { code: -32e3, message: "Session key mismatch" },
|
|
740
|
+
id: null
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
353
744
|
await entry.transport.handleRequest(req, res);
|
|
354
745
|
logRequest("DELETE", "ok", sessionId);
|
|
355
746
|
});
|
|
@@ -357,18 +748,40 @@ app.delete("/mcp", mcpLimiter, async (req, res) => {
|
|
|
357
748
|
logRequest("DELETE", "error", sessionId);
|
|
358
749
|
}
|
|
359
750
|
});
|
|
360
|
-
|
|
361
|
-
|
|
751
|
+
process.on("unhandledRejection", (reason) => {
|
|
752
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
753
|
+
console.error(`[MCP HTTP] Unhandled rejection: ${msg}`);
|
|
362
754
|
});
|
|
755
|
+
process.on("uncaughtException", (err) => {
|
|
756
|
+
console.error(`[MCP HTTP] Uncaught exception: ${err.stack ?? err.message}`);
|
|
757
|
+
gracefulShutdown();
|
|
758
|
+
});
|
|
759
|
+
var shuttingDown = false;
|
|
363
760
|
async function gracefulShutdown() {
|
|
761
|
+
if (shuttingDown) return;
|
|
762
|
+
shuttingDown = true;
|
|
763
|
+
setTimeout(() => process.exit(1), 3e3).unref();
|
|
364
764
|
console.log("Shutting down...");
|
|
365
765
|
for (const [, entry] of sessions) {
|
|
366
766
|
await entry.transport.close().catch(() => {
|
|
367
767
|
});
|
|
368
768
|
}
|
|
369
|
-
|
|
769
|
+
try {
|
|
770
|
+
await shutdownAnalytics();
|
|
771
|
+
} catch {
|
|
772
|
+
}
|
|
370
773
|
process.exit(0);
|
|
371
774
|
}
|
|
775
|
+
var LISTEN_HOST = "0.0.0.0";
|
|
776
|
+
var httpServer = app.listen(PORT, LISTEN_HOST, () => {
|
|
777
|
+
console.log(
|
|
778
|
+
`Product Brain MCP HTTP server v${SERVER_VERSION} listening on ${LISTEN_HOST}:${PORT}`
|
|
779
|
+
);
|
|
780
|
+
});
|
|
781
|
+
httpServer.on("error", (err) => {
|
|
782
|
+
console.error(`[MCP HTTP] Server error: ${err.message}`);
|
|
783
|
+
process.exit(1);
|
|
784
|
+
});
|
|
372
785
|
process.on("SIGINT", gracefulShutdown);
|
|
373
786
|
process.on("SIGTERM", gracefulShutdown);
|
|
374
787
|
//# sourceMappingURL=http.js.map
|