@kynver-app/mcp-estimator 0.2.8 → 0.2.11
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/lib/mcp-sse-server.d.ts +15 -0
- package/dist/lib/mcp-sse-server.js +103 -2
- package/dist/server.js +16 -0
- package/dist/tools/catalog-tools.js +1 -1
- package/dist/tools/email-pdf-tools.d.ts +45 -0
- package/dist/tools/email-pdf-tools.js +21 -0
- package/dist/tools/intake-tools.d.ts +463 -0
- package/dist/tools/intake-tools.js +82 -0
- package/dist/tools/price-catalog-tools.d.ts +96 -0
- package/dist/tools/price-catalog-tools.js +72 -0
- package/dist/tools/remodel-deliverable-tools.d.ts +1868 -0
- package/dist/tools/remodel-deliverable-tools.js +138 -0
- package/dist/tools/remodel-estimate-tools.d.ts +525 -0
- package/dist/tools/remodel-estimate-tools.js +71 -0
- package/dist/tools/takeoff-tools.d.ts +274 -0
- package/dist/tools/takeoff-tools.js +38 -4
- package/dist/tools/vision-tools.js +2 -2
- package/package.json +6 -1
- package/src/index.ts +0 -18
- package/src/lib/kynver-client.ts +0 -136
- package/src/lib/mcp-sse-server.ts +0 -249
- package/src/lib/package-version.ts +0 -55
- package/src/server.ts +0 -141
- package/src/sse.ts +0 -10
- package/src/tools/catalog-tools.ts +0 -217
- package/src/tools/client-tools.ts +0 -114
- package/src/tools/company-tools.ts +0 -108
- package/src/tools/email-pdf-tools.ts +0 -27
- package/src/tools/estimate-tools.ts +0 -281
- package/src/tools/intake-tools.ts +0 -95
- package/src/tools/photo-tools.ts +0 -60
- package/src/tools/price-catalog-tools.ts +0 -80
- package/src/tools/remodel-deliverable-tools.ts +0 -163
- package/src/tools/remodel-estimate-tools.ts +0 -80
- package/src/tools/stats-pdf-tools.ts +0 -80
- package/src/tools/takeoff-tools.ts +0 -319
- package/src/tools/template-tools.ts +0 -80
- package/src/tools/version-tools.ts +0 -39
- package/src/tools/vision-tools.ts +0 -48
- package/tsconfig.json +0 -15
|
@@ -23,6 +23,21 @@ export declare function headerValue(req: IncomingMessage, name: string): string
|
|
|
23
23
|
export declare function timingSafeEqualString(a: string, b: string): boolean;
|
|
24
24
|
export declare function hasForbiddenQueryAuth(url: URL): boolean;
|
|
25
25
|
export declare function resolveListenHost(): string;
|
|
26
|
+
/** Positive integer from env, else fallback. */
|
|
27
|
+
export declare function intFromEnv(name: string, fallback: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve + validate the Kynver API origin the server sends bearer keys to.
|
|
30
|
+
* A poisoned/misconfigured value would exfiltrate API keys, so fail closed on
|
|
31
|
+
* anything that isn't https (http is allowed only for localhost dev).
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveApiBaseUrl(): string;
|
|
34
|
+
/** First client IP from x-forwarded-for, else the socket address. */
|
|
35
|
+
export declare function clientIp(req: IncomingMessage): string;
|
|
36
|
+
type RateLimiter = {
|
|
37
|
+
check: (key: string) => boolean;
|
|
38
|
+
};
|
|
39
|
+
/** Minimal in-process fixed-window limiter (the SSE server is single-process). */
|
|
40
|
+
export declare function createFixedWindowRateLimiter(limit: number, windowMs: number): RateLimiter;
|
|
26
41
|
export declare function parseJsonBody(req: IncomingMessage, maxBytes?: number): Promise<unknown>;
|
|
27
42
|
export declare function validateMcporterServerName(name: string): string;
|
|
28
43
|
export declare function startKynverMcpSseServer(options: StartKynverMcpSseServerOptions): void;
|
|
@@ -26,6 +26,61 @@ export function resolveListenHost() {
|
|
|
26
26
|
const configured = process.env.MCP_SSE_HOST?.trim();
|
|
27
27
|
return configured || "127.0.0.1";
|
|
28
28
|
}
|
|
29
|
+
/** Positive integer from env, else fallback. */
|
|
30
|
+
export function intFromEnv(name, fallback) {
|
|
31
|
+
const raw = process.env[name];
|
|
32
|
+
if (!raw)
|
|
33
|
+
return fallback;
|
|
34
|
+
const n = parseInt(raw, 10);
|
|
35
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve + validate the Kynver API origin the server sends bearer keys to.
|
|
39
|
+
* A poisoned/misconfigured value would exfiltrate API keys, so fail closed on
|
|
40
|
+
* anything that isn't https (http is allowed only for localhost dev).
|
|
41
|
+
*/
|
|
42
|
+
export function resolveApiBaseUrl() {
|
|
43
|
+
const raw = process.env.KYNVER_API_URL?.trim();
|
|
44
|
+
if (!raw)
|
|
45
|
+
return "https://kynver.com";
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = new URL(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(`Invalid KYNVER_API_URL: ${raw}`);
|
|
52
|
+
}
|
|
53
|
+
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
54
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) {
|
|
55
|
+
throw new Error(`KYNVER_API_URL must be https (got "${parsed.protocol}//${parsed.hostname}"); API keys are sent to this origin.`);
|
|
56
|
+
}
|
|
57
|
+
return raw.replace(/\/$/, "");
|
|
58
|
+
}
|
|
59
|
+
/** First client IP from x-forwarded-for, else the socket address. */
|
|
60
|
+
export function clientIp(req) {
|
|
61
|
+
const fwd = headerValue(req, "x-forwarded-for");
|
|
62
|
+
if (fwd)
|
|
63
|
+
return fwd.split(",")[0].trim();
|
|
64
|
+
return req.socket?.remoteAddress ?? "unknown";
|
|
65
|
+
}
|
|
66
|
+
/** Minimal in-process fixed-window limiter (the SSE server is single-process). */
|
|
67
|
+
export function createFixedWindowRateLimiter(limit, windowMs) {
|
|
68
|
+
const hits = new Map();
|
|
69
|
+
return {
|
|
70
|
+
check(key) {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const entry = hits.get(key);
|
|
73
|
+
if (!entry || now >= entry.resetAt) {
|
|
74
|
+
hits.set(key, { count: 1, resetAt: now + windowMs });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (entry.count >= limit)
|
|
78
|
+
return false;
|
|
79
|
+
entry.count += 1;
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
29
84
|
export function parseJsonBody(req, maxBytes = MAX_SSE_BODY_BYTES) {
|
|
30
85
|
return new Promise((resolve, reject) => {
|
|
31
86
|
const chunks = [];
|
|
@@ -106,14 +161,37 @@ function json(res, status, body) {
|
|
|
106
161
|
}
|
|
107
162
|
export function startKynverMcpSseServer(options) {
|
|
108
163
|
const port = parseInt(process.env.PORT ?? String(options.defaultPort), 10);
|
|
109
|
-
const baseUrl =
|
|
164
|
+
const baseUrl = resolveApiBaseUrl();
|
|
110
165
|
const serviceSecret = process.env.KYNVER_SERVICE_SECRET ?? "";
|
|
111
166
|
const host = resolveListenHost();
|
|
112
167
|
const sessions = {};
|
|
168
|
+
// Resource / abuse limits (env-overridable; in-process per server).
|
|
169
|
+
const maxSessions = intFromEnv("MCP_SSE_MAX_SESSIONS", 200);
|
|
170
|
+
const maxSessionsPerUser = intFromEnv("MCP_SSE_MAX_SESSIONS_PER_USER", 20);
|
|
171
|
+
const idleTimeoutMs = intFromEnv("MCP_SSE_IDLE_TIMEOUT_MS", 5 * 60 * 1000);
|
|
172
|
+
const rateLimitPerMin = intFromEnv("MCP_SSE_RATE_LIMIT_PER_MIN", 120);
|
|
173
|
+
const rejectQueryApiKey = /^(1|true|yes)$/i.test(process.env.MCP_SSE_REJECT_QUERY_APIKEY ?? "");
|
|
174
|
+
const rateLimiter = createFixedWindowRateLimiter(rateLimitPerMin, 60 * 1000);
|
|
175
|
+
// Close idle SSE sessions so a flood of half-open connections can't pin memory.
|
|
176
|
+
const idleSweep = setInterval(() => {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
for (const [sid, entry] of Object.entries(sessions)) {
|
|
179
|
+
if (now - entry.lastActivity > idleTimeoutMs) {
|
|
180
|
+
delete sessions[sid];
|
|
181
|
+
void entry.transport.close().catch(() => { });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}, 60 * 1000);
|
|
185
|
+
idleSweep.unref?.();
|
|
113
186
|
const httpServer = createServer(async (req, res) => {
|
|
114
187
|
const url = parseUrl(req, port);
|
|
115
188
|
const path = url.pathname;
|
|
116
189
|
if (req.method === "GET" && path === "/sse") {
|
|
190
|
+
// Pre-auth rate limit by client IP (we don't know the user yet).
|
|
191
|
+
if (!rateLimiter.check(`sse:${clientIp(req)}`)) {
|
|
192
|
+
json(res, 429, { error: "Too many requests. Slow down and retry." });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
117
195
|
if (hasForbiddenQueryAuth(url)) {
|
|
118
196
|
json(res, 400, {
|
|
119
197
|
error: "Internal auth via query params is not allowed. Use X-Kynver-User-Id and X-Kynver-Service-Secret headers.",
|
|
@@ -121,6 +199,12 @@ export function startKynverMcpSseServer(options) {
|
|
|
121
199
|
return;
|
|
122
200
|
}
|
|
123
201
|
if (url.searchParams.has("apiKey")) {
|
|
202
|
+
if (rejectQueryApiKey) {
|
|
203
|
+
json(res, 400, {
|
|
204
|
+
error: "apiKey via query param is disabled. Use Authorization: Bearer <key>.",
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
124
208
|
console.warn("[MCP SSE] WARNING: apiKey in query string may leak via logs. Prefer Authorization header when your client supports it.");
|
|
125
209
|
}
|
|
126
210
|
let userId = null;
|
|
@@ -144,9 +228,20 @@ export function startKynverMcpSseServer(options) {
|
|
|
144
228
|
});
|
|
145
229
|
return;
|
|
146
230
|
}
|
|
231
|
+
// Connection caps — bound total + per-user concurrent SSE sessions so an
|
|
232
|
+
// authenticated caller can't exhaust memory/sockets by opening many.
|
|
233
|
+
const allEntries = Object.values(sessions);
|
|
234
|
+
if (allEntries.length >= maxSessions) {
|
|
235
|
+
json(res, 503, { error: "Server at capacity. Try again shortly." });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (allEntries.filter((s) => s.userId === userId).length >= maxSessionsPerUser) {
|
|
239
|
+
json(res, 429, { error: "Too many concurrent connections for this account." });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
147
242
|
console.log(`[MCP SSE] New connection (${options.label}, user: ${userId.slice(0, 8)}...)`);
|
|
148
243
|
const transport = new SSEServerTransport("/messages", res);
|
|
149
|
-
sessions[transport.sessionId] = { transport, userId, auth };
|
|
244
|
+
sessions[transport.sessionId] = { transport, userId, auth, lastActivity: Date.now() };
|
|
150
245
|
res.on("close", () => {
|
|
151
246
|
delete sessions[transport.sessionId];
|
|
152
247
|
});
|
|
@@ -165,6 +260,12 @@ export function startKynverMcpSseServer(options) {
|
|
|
165
260
|
json(res, 401, { error: "Unauthorized. Re-send the same credentials used to open the SSE session." });
|
|
166
261
|
return;
|
|
167
262
|
}
|
|
263
|
+
// Per-user rate limit on tool/message traffic.
|
|
264
|
+
if (!rateLimiter.check(`msg:${entry.userId}`)) {
|
|
265
|
+
json(res, 429, { error: "Too many requests. Slow down and retry." });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
entry.lastActivity = Date.now();
|
|
168
269
|
try {
|
|
169
270
|
const body = await parseJsonBody(req);
|
|
170
271
|
await options.requestContext.run({ userId: entry.userId }, async () => {
|
package/dist/server.js
CHANGED
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
6
|
import * as catalogTools from "./tools/catalog-tools.js";
|
|
7
|
+
import * as priceCatalogTools from "./tools/price-catalog-tools.js";
|
|
7
8
|
import * as estimateTools from "./tools/estimate-tools.js";
|
|
8
9
|
import * as templateTools from "./tools/template-tools.js";
|
|
9
10
|
import * as versionTools from "./tools/version-tools.js";
|
|
10
11
|
import * as photoTools from "./tools/photo-tools.js";
|
|
11
12
|
import * as statsPdfTools from "./tools/stats-pdf-tools.js";
|
|
13
|
+
import * as emailPdfTools from "./tools/email-pdf-tools.js";
|
|
12
14
|
import * as visionTools from "./tools/vision-tools.js";
|
|
13
15
|
import * as companyTools from "./tools/company-tools.js";
|
|
14
16
|
import * as clientTools from "./tools/client-tools.js";
|
|
15
17
|
import * as takeoffTools from "./tools/takeoff-tools.js";
|
|
18
|
+
import * as intakeTools from "./tools/intake-tools.js";
|
|
19
|
+
import * as remodelEstimateTools from "./tools/remodel-estimate-tools.js";
|
|
20
|
+
import * as remodelDeliverableTools from "./tools/remodel-deliverable-tools.js";
|
|
16
21
|
import { readOwnPackageVersion } from "./lib/package-version.js";
|
|
17
22
|
const PACKAGE_VERSION = readOwnPackageVersion(import.meta.url);
|
|
18
23
|
function toolResult(text) {
|
|
@@ -34,6 +39,11 @@ export function createEstimatorServer() {
|
|
|
34
39
|
register(catalogTools.getConfig.name, { description: catalogTools.getConfig.description, inputSchema: catalogTools.getConfig.inputSchemaZod }, (args) => catalogTools.getConfig.execute(args));
|
|
35
40
|
register(catalogTools.updateConfig.name, { description: catalogTools.updateConfig.description, inputSchema: catalogTools.updateConfig.inputSchemaZod }, (args) => catalogTools.updateConfig.execute(args));
|
|
36
41
|
register(catalogTools.importTemplate.name, { description: catalogTools.importTemplate.description, inputSchema: catalogTools.importTemplate.inputSchemaZod }, (args) => catalogTools.importTemplate.execute(args));
|
|
42
|
+
// --- Price catalog tools (4) ---
|
|
43
|
+
register(priceCatalogTools.listPriceCatalogs.name, { description: priceCatalogTools.listPriceCatalogs.description, inputSchema: priceCatalogTools.listPriceCatalogs.inputSchemaZod }, (args) => priceCatalogTools.listPriceCatalogs.execute(args));
|
|
44
|
+
register(priceCatalogTools.getPriceCatalog.name, { description: priceCatalogTools.getPriceCatalog.description, inputSchema: priceCatalogTools.getPriceCatalog.inputSchemaZod }, (args) => priceCatalogTools.getPriceCatalog.execute(args));
|
|
45
|
+
register(priceCatalogTools.resolvePrice.name, { description: priceCatalogTools.resolvePrice.description, inputSchema: priceCatalogTools.resolvePrice.inputSchemaZod }, (args) => priceCatalogTools.resolvePrice.execute(args));
|
|
46
|
+
register(priceCatalogTools.seedPriceCatalog.name, { description: priceCatalogTools.seedPriceCatalog.description, inputSchema: priceCatalogTools.seedPriceCatalog.inputSchemaZod }, (args) => priceCatalogTools.seedPriceCatalog.execute(args));
|
|
37
47
|
// --- Estimate tools (11) ---
|
|
38
48
|
register(estimateTools.createReplacement.name, { description: estimateTools.createReplacement.description, inputSchema: estimateTools.createReplacement.inputSchemaZod }, (args) => estimateTools.createReplacement.execute(args));
|
|
39
49
|
register(estimateTools.createRepair.name, { description: estimateTools.createRepair.description, inputSchema: estimateTools.createRepair.inputSchemaZod }, (args) => estimateTools.createRepair.execute(args));
|
|
@@ -46,6 +56,9 @@ export function createEstimatorServer() {
|
|
|
46
56
|
register(estimateTools.updateEstimate.name, { description: estimateTools.updateEstimate.description, inputSchema: estimateTools.updateEstimate.inputSchemaZod }, (args) => estimateTools.updateEstimate.execute(args));
|
|
47
57
|
register(estimateTools.deleteEstimate.name, { description: estimateTools.deleteEstimate.description, inputSchema: estimateTools.deleteEstimate.inputSchemaZod }, (args) => estimateTools.deleteEstimate.execute(args));
|
|
48
58
|
register(estimateTools.duplicateEstimate.name, { description: estimateTools.duplicateEstimate.description, inputSchema: estimateTools.duplicateEstimate.inputSchemaZod }, (args) => estimateTools.duplicateEstimate.execute(args));
|
|
59
|
+
register(remodelEstimateTools.buildRemodelEstimate.name, { description: remodelEstimateTools.buildRemodelEstimate.description, inputSchema: remodelEstimateTools.buildRemodelEstimate.inputSchemaZod }, (args) => remodelEstimateTools.buildRemodelEstimate.execute(args));
|
|
60
|
+
register(remodelDeliverableTools.buildRemodelDeliverable.name, { description: remodelDeliverableTools.buildRemodelDeliverable.description, inputSchema: remodelDeliverableTools.buildRemodelDeliverable.inputSchemaZod }, (args) => remodelDeliverableTools.buildRemodelDeliverable.execute(args));
|
|
61
|
+
register(remodelDeliverableTools.draftRemodelDeliverable.name, { description: remodelDeliverableTools.draftRemodelDeliverable.description, inputSchema: remodelDeliverableTools.draftRemodelDeliverable.inputSchemaZod }, (args) => remodelDeliverableTools.draftRemodelDeliverable.execute(args));
|
|
49
62
|
// --- Template tools (4) ---
|
|
50
63
|
register(templateTools.saveTemplate.name, { description: templateTools.saveTemplate.description, inputSchema: templateTools.saveTemplate.inputSchemaZod }, (args) => templateTools.saveTemplate.execute(args));
|
|
51
64
|
register(templateTools.listTemplates.name, { description: templateTools.listTemplates.description, inputSchema: templateTools.listTemplates.inputSchemaZod }, (args) => templateTools.listTemplates.execute(args));
|
|
@@ -62,6 +75,7 @@ export function createEstimatorServer() {
|
|
|
62
75
|
register(statsPdfTools.getStats.name, { description: statsPdfTools.getStats.description, inputSchema: statsPdfTools.getStats.inputSchemaZod }, (args) => statsPdfTools.getStats.execute(args));
|
|
63
76
|
register(statsPdfTools.exportPdf.name, { description: statsPdfTools.exportPdf.description, inputSchema: statsPdfTools.exportPdf.inputSchemaZod }, (args) => statsPdfTools.exportPdf.execute(args));
|
|
64
77
|
register(statsPdfTools.updateOutputConfig.name, { description: statsPdfTools.updateOutputConfig.description, inputSchema: statsPdfTools.updateOutputConfig.inputSchemaZod }, (args) => statsPdfTools.updateOutputConfig.execute(args));
|
|
78
|
+
register(emailPdfTools.emailPdf.name, { description: emailPdfTools.emailPdf.description, inputSchema: emailPdfTools.emailPdf.inputSchemaZod }, (args) => emailPdfTools.emailPdf.execute(args));
|
|
65
79
|
// --- Vision tools (2) ---
|
|
66
80
|
register(visionTools.analyzePhotos.name, { description: visionTools.analyzePhotos.description, inputSchema: visionTools.analyzePhotos.inputSchemaZod }, (args) => visionTools.analyzePhotos.execute(args));
|
|
67
81
|
register(visionTools.analyzeDocument.name, { description: visionTools.analyzeDocument.description, inputSchema: visionTools.analyzeDocument.inputSchemaZod }, (args) => visionTools.analyzeDocument.execute(args));
|
|
@@ -94,5 +108,7 @@ export function createEstimatorServer() {
|
|
|
94
108
|
register(takeoffTools.requestEstimateApproval.name, { description: takeoffTools.requestEstimateApproval.description, inputSchema: takeoffTools.requestEstimateApproval.inputSchemaZod }, (args) => takeoffTools.requestEstimateApproval.execute(args));
|
|
95
109
|
register(takeoffTools.approveEstimate.name, { description: takeoffTools.approveEstimate.description, inputSchema: takeoffTools.approveEstimate.inputSchemaZod }, (args) => takeoffTools.approveEstimate.execute(args));
|
|
96
110
|
register(takeoffTools.getEstimateApproval.name, { description: takeoffTools.getEstimateApproval.description, inputSchema: takeoffTools.getEstimateApproval.inputSchemaZod }, (args) => takeoffTools.getEstimateApproval.execute(args));
|
|
111
|
+
// --- Intake tools (1) ---
|
|
112
|
+
register(intakeTools.normalizeRemodelIntake.name, { description: intakeTools.normalizeRemodelIntake.description, inputSchema: intakeTools.normalizeRemodelIntake.inputSchemaZod }, (args) => intakeTools.normalizeRemodelIntake.execute(args));
|
|
97
113
|
return server;
|
|
98
114
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const emailPdfSchema: z.ZodObject<{
|
|
3
|
+
estimateId: z.ZodString;
|
|
4
|
+
recipient: z.ZodOptional<z.ZodString>;
|
|
5
|
+
subject: z.ZodOptional<z.ZodString>;
|
|
6
|
+
message: z.ZodOptional<z.ZodString>;
|
|
7
|
+
confirmExternalRecipient: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
estimateId: string;
|
|
10
|
+
confirmExternalRecipient: boolean;
|
|
11
|
+
message?: string | undefined;
|
|
12
|
+
recipient?: string | undefined;
|
|
13
|
+
subject?: string | undefined;
|
|
14
|
+
}, {
|
|
15
|
+
estimateId: string;
|
|
16
|
+
message?: string | undefined;
|
|
17
|
+
recipient?: string | undefined;
|
|
18
|
+
subject?: string | undefined;
|
|
19
|
+
confirmExternalRecipient?: boolean | undefined;
|
|
20
|
+
}>;
|
|
21
|
+
export declare const emailPdf: {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
inputSchemaZod: z.ZodObject<{
|
|
25
|
+
estimateId: z.ZodString;
|
|
26
|
+
recipient: z.ZodOptional<z.ZodString>;
|
|
27
|
+
subject: z.ZodOptional<z.ZodString>;
|
|
28
|
+
message: z.ZodOptional<z.ZodString>;
|
|
29
|
+
confirmExternalRecipient: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
30
|
+
}, "strip", z.ZodTypeAny, {
|
|
31
|
+
estimateId: string;
|
|
32
|
+
confirmExternalRecipient: boolean;
|
|
33
|
+
message?: string | undefined;
|
|
34
|
+
recipient?: string | undefined;
|
|
35
|
+
subject?: string | undefined;
|
|
36
|
+
}, {
|
|
37
|
+
estimateId: string;
|
|
38
|
+
message?: string | undefined;
|
|
39
|
+
recipient?: string | undefined;
|
|
40
|
+
subject?: string | undefined;
|
|
41
|
+
confirmExternalRecipient?: boolean | undefined;
|
|
42
|
+
}>;
|
|
43
|
+
execute: (args: z.infer<typeof emailPdfSchema>) => Promise<string>;
|
|
44
|
+
};
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as client from "../lib/kynver-client.js";
|
|
3
|
+
const emailPdfSchema = z.object({
|
|
4
|
+
estimateId: z.string(),
|
|
5
|
+
recipient: z.string().email().optional(),
|
|
6
|
+
subject: z.string().min(1).max(200).optional(),
|
|
7
|
+
message: z.string().min(1).max(10_000).optional(),
|
|
8
|
+
confirmExternalRecipient: z.boolean().optional().default(false),
|
|
9
|
+
});
|
|
10
|
+
export const emailPdf = {
|
|
11
|
+
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 other address requires confirmExternalRecipient=true after explicit user confirmation. Respects the estimate approval gate before customer send. Returns send status and audit metadata.",
|
|
13
|
+
inputSchemaZod: emailPdfSchema,
|
|
14
|
+
execute: async (args) => {
|
|
15
|
+
if (!client.hasApiKey())
|
|
16
|
+
return client.noApiKeyMessage();
|
|
17
|
+
const { estimateId, ...body } = args;
|
|
18
|
+
const data = await client.post(`/estimator/estimates/${estimateId}/email-pdf`, body);
|
|
19
|
+
return JSON.stringify(data);
|
|
20
|
+
},
|
|
21
|
+
};
|