@kynver-app/mcp-estimator 0.2.14 → 0.2.16
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.
|
@@ -36,8 +36,13 @@ function getHeaders() {
|
|
|
36
36
|
const userId = ctx?.userId ?? process.env.KYNVER_USER_ID?.trim();
|
|
37
37
|
const serviceSecret = process.env.KYNVER_SERVICE_SECRET?.trim();
|
|
38
38
|
if (userId) {
|
|
39
|
+
const runnerToken = process.env.KYNVER_RUNNER_TOKEN?.trim();
|
|
40
|
+
if (runnerToken) {
|
|
41
|
+
headers["X-Kynver-Runner-Token"] = runnerToken;
|
|
42
|
+
return headers;
|
|
43
|
+
}
|
|
39
44
|
if (!serviceSecret) {
|
|
40
|
-
throw new Error("
|
|
45
|
+
throw new Error("User context requires authentication: set KYNVER_RUNNER_TOKEN (preferred) or KYNVER_SERVICE_SECRET in the MCP server environment.");
|
|
41
46
|
}
|
|
42
47
|
headers["X-Kynver-User-Id"] = userId;
|
|
43
48
|
headers["X-Kynver-Service-Secret"] = serviceSecret;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type IncomingMessage } from "node:http";
|
|
1
|
+
import { createServer, type IncomingMessage } from "node:http";
|
|
2
2
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
export declare const MAX_SSE_BODY_BYTES: number;
|
|
4
4
|
export type SessionAuth = {
|
|
@@ -40,5 +40,5 @@ type RateLimiter = {
|
|
|
40
40
|
export declare function createFixedWindowRateLimiter(limit: number, windowMs: number): RateLimiter;
|
|
41
41
|
export declare function parseJsonBody(req: IncomingMessage, maxBytes?: number): Promise<unknown>;
|
|
42
42
|
export declare function validateMcporterServerName(name: string): string;
|
|
43
|
-
export declare function startKynverMcpSseServer(options: StartKynverMcpSseServerOptions):
|
|
43
|
+
export declare function startKynverMcpSseServer(options: StartKynverMcpSseServerOptions): ReturnType<typeof createServer>;
|
|
44
44
|
export {};
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
// @generated — synced from packages/kynver-mcp-sse-lib/src/index.ts via scripts/sync-mcp-sse-server.mjs
|
|
2
2
|
// Do not edit by hand; change the canonical file and re-run the sync script.
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
-
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
6
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
7
|
export const MAX_SSE_BODY_BYTES = 1 * 1024 * 1024;
|
|
7
8
|
const FORBIDDEN_QUERY_KEYS = ["apiKey", "secret", "userId"];
|
|
9
|
+
const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]);
|
|
10
|
+
function isServiceSecretImpersonationRejectedInProduction() {
|
|
11
|
+
const override = process.env.KYNVER_ALLOW_SERVICE_SECRET_IMPERSONATION?.trim().toLowerCase();
|
|
12
|
+
if (override !== undefined && TRUTHY_ENV.has(override))
|
|
13
|
+
return false;
|
|
14
|
+
return process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production";
|
|
15
|
+
}
|
|
8
16
|
export function headerValue(req, name) {
|
|
9
17
|
const v = req.headers[name];
|
|
10
18
|
return typeof v === "string" ? v : Array.isArray(v) ? (v[0] ?? null) : null;
|
|
@@ -121,6 +129,8 @@ function resolveInternalAuth(req, serviceSecret) {
|
|
|
121
129
|
const internalSecret = headerValue(req, "x-kynver-service-secret");
|
|
122
130
|
if (!internalUserId || !serviceSecret || !internalSecret)
|
|
123
131
|
return null;
|
|
132
|
+
if (isServiceSecretImpersonationRejectedInProduction())
|
|
133
|
+
return null;
|
|
124
134
|
if (!timingSafeEqualString(internalSecret, serviceSecret))
|
|
125
135
|
return null;
|
|
126
136
|
return { userId: internalUserId, auth: { kind: "internal", userId: internalUserId } };
|
|
@@ -162,13 +172,15 @@ export function startKynverMcpSseServer(options) {
|
|
|
162
172
|
const serviceSecret = process.env.KYNVER_SERVICE_SECRET ?? "";
|
|
163
173
|
const host = resolveListenHost();
|
|
164
174
|
const sessions = {};
|
|
175
|
+
const streamableSessions = {};
|
|
165
176
|
// Resource / abuse limits (env-overridable; in-process per server).
|
|
166
177
|
const maxSessions = intFromEnv("MCP_SSE_MAX_SESSIONS", 200);
|
|
167
178
|
const maxSessionsPerUser = intFromEnv("MCP_SSE_MAX_SESSIONS_PER_USER", 20);
|
|
168
179
|
const idleTimeoutMs = intFromEnv("MCP_SSE_IDLE_TIMEOUT_MS", 5 * 60 * 1000);
|
|
169
180
|
const rateLimitPerMin = intFromEnv("MCP_SSE_RATE_LIMIT_PER_MIN", 120);
|
|
170
181
|
const rateLimiter = createFixedWindowRateLimiter(rateLimitPerMin, 60 * 1000);
|
|
171
|
-
// Close idle
|
|
182
|
+
// Close idle sessions (both transports) so a flood of half-open connections
|
|
183
|
+
// can't pin memory.
|
|
172
184
|
const idleSweep = setInterval(() => {
|
|
173
185
|
const now = Date.now();
|
|
174
186
|
for (const [sid, entry] of Object.entries(sessions)) {
|
|
@@ -177,8 +189,17 @@ export function startKynverMcpSseServer(options) {
|
|
|
177
189
|
void entry.transport.close().catch(() => { });
|
|
178
190
|
}
|
|
179
191
|
}
|
|
192
|
+
for (const [sid, entry] of Object.entries(streamableSessions)) {
|
|
193
|
+
if (now - entry.lastActivity > idleTimeoutMs) {
|
|
194
|
+
delete streamableSessions[sid];
|
|
195
|
+
void entry.transport.close().catch(() => { });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
180
198
|
}, 60 * 1000);
|
|
181
199
|
idleSweep.unref?.();
|
|
200
|
+
const activeSessionCount = () => Object.keys(sessions).length + Object.keys(streamableSessions).length;
|
|
201
|
+
const activeSessionCountForUser = (userId) => Object.values(sessions).filter((s) => s.userId === userId).length +
|
|
202
|
+
Object.values(streamableSessions).filter((s) => s.userId === userId).length;
|
|
182
203
|
const httpServer = createServer(async (req, res) => {
|
|
183
204
|
const url = parseUrl(req, port);
|
|
184
205
|
const path = url.pathname;
|
|
@@ -215,14 +236,14 @@ export function startKynverMcpSseServer(options) {
|
|
|
215
236
|
});
|
|
216
237
|
return;
|
|
217
238
|
}
|
|
218
|
-
// Connection caps — bound total + per-user concurrent
|
|
219
|
-
// authenticated caller can't exhaust
|
|
220
|
-
|
|
221
|
-
if (
|
|
239
|
+
// Connection caps — bound total + per-user concurrent sessions (across
|
|
240
|
+
// both transports) so an authenticated caller can't exhaust
|
|
241
|
+
// memory/sockets by opening many.
|
|
242
|
+
if (activeSessionCount() >= maxSessions) {
|
|
222
243
|
json(res, 503, { error: "Server at capacity. Try again shortly." });
|
|
223
244
|
return;
|
|
224
245
|
}
|
|
225
|
-
if (
|
|
246
|
+
if (activeSessionCountForUser(userId) >= maxSessionsPerUser) {
|
|
226
247
|
json(res, 429, { error: "Too many concurrent connections for this account." });
|
|
227
248
|
return;
|
|
228
249
|
}
|
|
@@ -266,6 +287,146 @@ export function startKynverMcpSseServer(options) {
|
|
|
266
287
|
}
|
|
267
288
|
return;
|
|
268
289
|
}
|
|
290
|
+
// Streamable HTTP transport (MCP spec 2025-03-26+) — the transport modern
|
|
291
|
+
// connector clients (Grok, ChatGPT, Gemini, Claude) expect for remote
|
|
292
|
+
// servers. One path, three verbs: POST carries JSON-RPC (initialize opens a
|
|
293
|
+
// session; the response's mcp-session-id header identifies it), GET opens
|
|
294
|
+
// the standalone server→client SSE stream, DELETE terminates the session.
|
|
295
|
+
// Auth model mirrors the legacy /sse pair: credentials are verified on the
|
|
296
|
+
// initialize POST, bound to the session, and every subsequent request must
|
|
297
|
+
// re-present the same credentials.
|
|
298
|
+
if (path === "/mcp") {
|
|
299
|
+
if (req.method !== "POST" && req.method !== "GET" && req.method !== "DELETE") {
|
|
300
|
+
res.writeHead(405, { "Content-Type": "application/json", Allow: "GET, POST, DELETE" });
|
|
301
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (hasForbiddenQueryAuth(url)) {
|
|
305
|
+
json(res, 400, {
|
|
306
|
+
error: "Internal auth via query params is not allowed. Use X-Kynver-User-Id and X-Kynver-Service-Secret headers.",
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const sessionId = headerValue(req, "mcp-session-id");
|
|
311
|
+
if (sessionId) {
|
|
312
|
+
const entry = streamableSessions[sessionId];
|
|
313
|
+
if (!entry) {
|
|
314
|
+
// 404 (not 400) per spec so a well-behaved client re-initializes.
|
|
315
|
+
json(res, 404, { error: "Unknown or expired MCP session. Re-initialize." });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (!verifyPostAuth(req, url, entry.auth, serviceSecret)) {
|
|
319
|
+
json(res, 401, {
|
|
320
|
+
error: "Unauthorized. Re-send the same credentials used to initialize the MCP session.",
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!rateLimiter.check(`msg:${entry.userId}`)) {
|
|
325
|
+
json(res, 429, { error: "Too many requests. Slow down and retry." });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
entry.lastActivity = Date.now();
|
|
329
|
+
try {
|
|
330
|
+
const body = req.method === "POST" ? await parseJsonBody(req) : undefined;
|
|
331
|
+
await options.requestContext.run({ userId: entry.userId }, async () => {
|
|
332
|
+
await entry.transport.handleRequest(req, res, body);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (!res.headersSent) {
|
|
337
|
+
json(res, 400, {
|
|
338
|
+
error: error instanceof Error ? error.message : "Invalid request body",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// No session header — only an initialize POST may open a session. (The
|
|
345
|
+
// transport itself rejects a sessionless non-initialize POST with 400.)
|
|
346
|
+
if (req.method !== "POST") {
|
|
347
|
+
json(res, 400, {
|
|
348
|
+
error: "Missing mcp-session-id header. Open a session with an initialize POST first.",
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// Pre-auth rate limit by client IP (we don't know the user yet).
|
|
353
|
+
if (!rateLimiter.check(`mcp:${clientIp(req)}`)) {
|
|
354
|
+
json(res, 429, { error: "Too many requests. Slow down and retry." });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
let userId = null;
|
|
358
|
+
let auth = null;
|
|
359
|
+
const internal = serviceSecret ? resolveInternalAuth(req, serviceSecret) : null;
|
|
360
|
+
if (internal) {
|
|
361
|
+
userId = internal.userId;
|
|
362
|
+
auth = internal.auth;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
const apiKey = bearerToken(req);
|
|
366
|
+
if (apiKey) {
|
|
367
|
+
userId = await resolveUserFromApiKey(baseUrl, apiKey);
|
|
368
|
+
if (userId)
|
|
369
|
+
auth = { kind: "apiKey", apiKey };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!userId || !auth) {
|
|
373
|
+
json(res, 401, {
|
|
374
|
+
error: "Unauthorized. Pass API key via Authorization: Bearer <key> or internal X-Kynver-User-Id + X-Kynver-Service-Secret headers.",
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (activeSessionCount() >= maxSessions) {
|
|
379
|
+
json(res, 503, { error: "Server at capacity. Try again shortly." });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (activeSessionCountForUser(userId) >= maxSessionsPerUser) {
|
|
383
|
+
json(res, 429, { error: "Too many concurrent connections for this account." });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
let body;
|
|
387
|
+
try {
|
|
388
|
+
body = await parseJsonBody(req);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
json(res, 400, {
|
|
392
|
+
error: error instanceof Error ? error.message : "Invalid request body",
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const boundUserId = userId;
|
|
397
|
+
const boundAuth = auth;
|
|
398
|
+
const transport = new StreamableHTTPServerTransport({
|
|
399
|
+
sessionIdGenerator: () => randomUUID(),
|
|
400
|
+
onsessioninitialized: (sid) => {
|
|
401
|
+
streamableSessions[sid] = {
|
|
402
|
+
transport,
|
|
403
|
+
userId: boundUserId,
|
|
404
|
+
auth: boundAuth,
|
|
405
|
+
lastActivity: Date.now(),
|
|
406
|
+
};
|
|
407
|
+
console.log(`[MCP HTTP] New session (${options.label}, user: ${boundUserId.slice(0, 8)}...)`);
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
transport.onclose = () => {
|
|
411
|
+
if (transport.sessionId)
|
|
412
|
+
delete streamableSessions[transport.sessionId];
|
|
413
|
+
};
|
|
414
|
+
const server = await options.createMcpServer();
|
|
415
|
+
await server.connect(transport);
|
|
416
|
+
try {
|
|
417
|
+
await options.requestContext.run({ userId: boundUserId }, async () => {
|
|
418
|
+
await transport.handleRequest(req, res, body);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (!res.headersSent) {
|
|
423
|
+
json(res, 400, {
|
|
424
|
+
error: error instanceof Error ? error.message : "Invalid request",
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
269
430
|
if (req.method === "GET" && path === "/health") {
|
|
270
431
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
271
432
|
res.end("ok");
|
|
@@ -276,4 +437,5 @@ export function startKynverMcpSseServer(options) {
|
|
|
276
437
|
httpServer.listen(port, host, () => {
|
|
277
438
|
console.log(`[MCP SSE] ${options.label} listening on ${host}:${port}`);
|
|
278
439
|
});
|
|
440
|
+
return httpServer;
|
|
279
441
|
}
|
package/dist/sse.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createEstimatorServer } from "./server.js";
|
|
|
3
3
|
import { requestContext } from "./lib/kynver-client.js";
|
|
4
4
|
startKynverMcpSseServer({
|
|
5
5
|
label: "Estimator server",
|
|
6
|
-
defaultPort:
|
|
6
|
+
defaultPort: 3104,
|
|
7
7
|
createMcpServer: createEstimatorServer,
|
|
8
8
|
requestContext,
|
|
9
9
|
});
|
|
@@ -2,11 +2,13 @@ import { z } from "zod";
|
|
|
2
2
|
declare const emailPdfSchema: z.ZodObject<{
|
|
3
3
|
estimateId: z.ZodString;
|
|
4
4
|
recipient: z.ZodOptional<z.ZodString>;
|
|
5
|
+
ccRecipients: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
|
|
5
6
|
subject: z.ZodOptional<z.ZodString>;
|
|
6
7
|
message: z.ZodOptional<z.ZodString>;
|
|
7
8
|
confirmExternalRecipient: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
8
9
|
}, "strip", z.ZodTypeAny, {
|
|
9
10
|
estimateId: string;
|
|
11
|
+
ccRecipients: string[];
|
|
10
12
|
confirmExternalRecipient: boolean;
|
|
11
13
|
message?: string | undefined;
|
|
12
14
|
recipient?: string | undefined;
|
|
@@ -15,6 +17,7 @@ declare const emailPdfSchema: z.ZodObject<{
|
|
|
15
17
|
estimateId: string;
|
|
16
18
|
message?: string | undefined;
|
|
17
19
|
recipient?: string | undefined;
|
|
20
|
+
ccRecipients?: string[] | undefined;
|
|
18
21
|
subject?: string | undefined;
|
|
19
22
|
confirmExternalRecipient?: boolean | undefined;
|
|
20
23
|
}>;
|
|
@@ -24,11 +27,13 @@ export declare const emailPdf: {
|
|
|
24
27
|
inputSchemaZod: z.ZodObject<{
|
|
25
28
|
estimateId: z.ZodString;
|
|
26
29
|
recipient: z.ZodOptional<z.ZodString>;
|
|
30
|
+
ccRecipients: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
|
|
27
31
|
subject: z.ZodOptional<z.ZodString>;
|
|
28
32
|
message: z.ZodOptional<z.ZodString>;
|
|
29
33
|
confirmExternalRecipient: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
30
34
|
}, "strip", z.ZodTypeAny, {
|
|
31
35
|
estimateId: string;
|
|
36
|
+
ccRecipients: string[];
|
|
32
37
|
confirmExternalRecipient: boolean;
|
|
33
38
|
message?: string | undefined;
|
|
34
39
|
recipient?: string | undefined;
|
|
@@ -37,6 +42,7 @@ export declare const emailPdf: {
|
|
|
37
42
|
estimateId: string;
|
|
38
43
|
message?: string | undefined;
|
|
39
44
|
recipient?: string | undefined;
|
|
45
|
+
ccRecipients?: string[] | undefined;
|
|
40
46
|
subject?: string | undefined;
|
|
41
47
|
confirmExternalRecipient?: boolean | undefined;
|
|
42
48
|
}>;
|
|
@@ -3,13 +3,14 @@ import * as client from "../lib/kynver-client.js";
|
|
|
3
3
|
const emailPdfSchema = z.object({
|
|
4
4
|
estimateId: z.string(),
|
|
5
5
|
recipient: z.string().email().optional(),
|
|
6
|
+
ccRecipients: z.array(z.string().email()).max(10).optional().default([]),
|
|
6
7
|
subject: z.string().min(1).max(200).optional(),
|
|
7
8
|
message: z.string().min(1).max(10_000).optional(),
|
|
8
9
|
confirmExternalRecipient: z.boolean().optional().default(false),
|
|
9
10
|
});
|
|
10
11
|
export const emailPdf = {
|
|
11
12
|
name: "estimator_email_pdf",
|
|
12
|
-
description: "Email an estimate PDF to a recipient. Fetches and attaches the PDF server-side (no secrets exposed). Defaults to the account owner's email when recipient is omitted. Estimate client emails and linked client records are trusted without extra confirmation; any
|
|
13
|
+
description: "Email an estimate PDF to a primary recipient with optional CC recipients in one group email. Fetches and attaches the PDF server-side (no secrets exposed). Defaults to the account owner's email when recipient is omitted. Estimate client emails and linked client records are trusted without extra confirmation; any external To or CC address requires confirmExternalRecipient=true after explicit user confirmation. Respects the estimate approval gate before customer send. Returns send status and audit metadata.",
|
|
13
14
|
inputSchemaZod: emailPdfSchema,
|
|
14
15
|
execute: async (args) => {
|
|
15
16
|
if (!client.hasApiKey())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kynver-app/mcp-estimator",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"!dist/**/__tests__/**",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"node": ">=18"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
26
26
|
"zod": "^3.23.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|