@kynver-app/mcp-estimator 0.1.1
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/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/lib/kynver-client.d.ts +20 -0
- package/dist/lib/kynver-client.js +110 -0
- package/dist/lib/mcp-sse-server.d.ts +29 -0
- package/dist/lib/mcp-sse-server.js +191 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +78 -0
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +9 -0
- package/dist/tools/catalog-tools.d.ts +387 -0
- package/dist/tools/catalog-tools.js +193 -0
- package/dist/tools/client-tools.d.ts +193 -0
- package/dist/tools/client-tools.js +105 -0
- package/dist/tools/company-tools.d.ts +163 -0
- package/dist/tools/company-tools.js +98 -0
- package/dist/tools/create-estimate.d.ts +107 -0
- package/dist/tools/create-estimate.js +64 -0
- package/dist/tools/estimate-tools.d.ts +531 -0
- package/dist/tools/estimate-tools.js +259 -0
- package/dist/tools/estimator-chat.d.ts +25 -0
- package/dist/tools/estimator-chat.js +15 -0
- package/dist/tools/get-estimate.d.ts +19 -0
- package/dist/tools/get-estimate.js +14 -0
- package/dist/tools/list-catalog.d.ts +25 -0
- package/dist/tools/list-catalog.js +22 -0
- package/dist/tools/photo-tools.d.ts +77 -0
- package/dist/tools/photo-tools.js +51 -0
- package/dist/tools/stats-pdf-tools.d.ts +113 -0
- package/dist/tools/stats-pdf-tools.js +73 -0
- package/dist/tools/template-tools.d.ts +96 -0
- package/dist/tools/template-tools.js +70 -0
- package/dist/tools/version-tools.d.ts +46 -0
- package/dist/tools/version-tools.js +33 -0
- package/dist/tools/vision-tools.d.ts +52 -0
- package/dist/tools/vision-tools.js +42 -0
- package/package.json +27 -0
- package/src/index.ts +13 -0
- package/src/lib/kynver-client.ts +136 -0
- package/src/lib/mcp-sse-server.ts +249 -0
- package/src/server.ts +101 -0
- package/src/sse.ts +10 -0
- package/src/tools/catalog-tools.ts +217 -0
- package/src/tools/client-tools.ts +114 -0
- package/src/tools/company-tools.ts +108 -0
- package/src/tools/estimate-tools.ts +281 -0
- package/src/tools/photo-tools.ts +60 -0
- package/src/tools/stats-pdf-tools.ts +80 -0
- package/src/tools/template-tools.ts +80 -0
- package/src/tools/version-tools.ts +39 -0
- package/src/tools/vision-tools.ts +48 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// @generated — synced from packages/kynver-mcp-sse-lib/src/index.ts via scripts/sync-mcp-sse-server.mjs
|
|
2
|
+
// Do not edit by hand; change the canonical file and re-run the sync script.
|
|
3
|
+
|
|
4
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
5
|
+
import { timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
7
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
|
|
9
|
+
export const MAX_SSE_BODY_BYTES = 1 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export type SessionAuth =
|
|
12
|
+
| { kind: "apiKey"; apiKey: string }
|
|
13
|
+
| { kind: "internal"; userId: string };
|
|
14
|
+
|
|
15
|
+
type SessionEntry = {
|
|
16
|
+
transport: SSEServerTransport;
|
|
17
|
+
userId: string;
|
|
18
|
+
auth: SessionAuth;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type RequestContextStore = {
|
|
22
|
+
run: <T>(store: { userId?: string }, fn: () => Promise<T>) => Promise<T>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type StartKynverMcpSseServerOptions = {
|
|
26
|
+
label: string;
|
|
27
|
+
defaultPort: number;
|
|
28
|
+
createMcpServer: () => McpServer | Promise<McpServer>;
|
|
29
|
+
requestContext: RequestContextStore;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const FORBIDDEN_QUERY_KEYS = ["secret", "userId"] as const;
|
|
33
|
+
|
|
34
|
+
export function headerValue(req: IncomingMessage, name: string): string | null {
|
|
35
|
+
const v = req.headers[name];
|
|
36
|
+
return typeof v === "string" ? v : Array.isArray(v) ? (v[0] ?? null) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function timingSafeEqualString(a: string, b: string): boolean {
|
|
40
|
+
const left = Buffer.from(a);
|
|
41
|
+
const right = Buffer.from(b);
|
|
42
|
+
if (left.length !== right.length) return false;
|
|
43
|
+
return timingSafeEqual(left, right);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasForbiddenQueryAuth(url: URL): boolean {
|
|
47
|
+
return FORBIDDEN_QUERY_KEYS.some((key) => url.searchParams.has(key));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function queryApiKey(url: URL): string {
|
|
51
|
+
return url.searchParams.get("apiKey")?.trim() ?? "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveListenHost(): string {
|
|
55
|
+
const configured = process.env.MCP_SSE_HOST?.trim();
|
|
56
|
+
return configured || "127.0.0.1";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseJsonBody(req: IncomingMessage, maxBytes = MAX_SSE_BODY_BYTES): Promise<unknown> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const chunks: Buffer[] = [];
|
|
62
|
+
let totalSize = 0;
|
|
63
|
+
req.on("data", (chunk: Buffer) => {
|
|
64
|
+
totalSize += chunk.length;
|
|
65
|
+
if (totalSize > maxBytes) {
|
|
66
|
+
req.destroy();
|
|
67
|
+
reject(new Error("Request body too large"));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
chunks.push(chunk);
|
|
71
|
+
});
|
|
72
|
+
req.on("end", () => {
|
|
73
|
+
try {
|
|
74
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
reject(error);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
req.on("error", reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function validateMcporterServerName(name: string): string {
|
|
84
|
+
const trimmed = name.trim();
|
|
85
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
86
|
+
throw new Error("Invalid mcporter server name.");
|
|
87
|
+
}
|
|
88
|
+
return trimmed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function bearerToken(req: IncomingMessage): string {
|
|
92
|
+
const auth = headerValue(req, "authorization");
|
|
93
|
+
if (!auth) return "";
|
|
94
|
+
const match = /^Bearer\s+(.+)$/i.exec(auth);
|
|
95
|
+
return match?.[1]?.trim() ?? "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveInternalAuth(
|
|
99
|
+
req: IncomingMessage,
|
|
100
|
+
serviceSecret: string,
|
|
101
|
+
): { userId: string; auth: SessionAuth } | null {
|
|
102
|
+
const internalUserId = headerValue(req, "x-kynver-user-id");
|
|
103
|
+
const internalSecret = headerValue(req, "x-kynver-service-secret");
|
|
104
|
+
if (!internalUserId || !serviceSecret || !internalSecret) return null;
|
|
105
|
+
if (!timingSafeEqualString(internalSecret, serviceSecret)) return null;
|
|
106
|
+
return { userId: internalUserId, auth: { kind: "internal", userId: internalUserId } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function verifyPostAuth(
|
|
110
|
+
req: IncomingMessage,
|
|
111
|
+
url: URL,
|
|
112
|
+
sessionAuth: SessionAuth,
|
|
113
|
+
serviceSecret: string,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (sessionAuth.kind === "apiKey") {
|
|
116
|
+
const token = bearerToken(req) || queryApiKey(url);
|
|
117
|
+
return token.length > 0 && timingSafeEqualString(token, sessionAuth.apiKey);
|
|
118
|
+
}
|
|
119
|
+
const internal = resolveInternalAuth(req, serviceSecret);
|
|
120
|
+
return (
|
|
121
|
+
internal !== null &&
|
|
122
|
+
internal.userId === sessionAuth.userId &&
|
|
123
|
+
internal.auth.kind === "internal"
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function resolveUserFromApiKey(baseUrl: string, apiKey: string): Promise<string | null> {
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/users/me`, {
|
|
130
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) return null;
|
|
133
|
+
const data = (await res.json()) as { id?: string };
|
|
134
|
+
return data.id ?? null;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseUrl(req: IncomingMessage, port: number): URL {
|
|
141
|
+
return new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function json(res: ServerResponse, status: number, body: Record<string, unknown>) {
|
|
145
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
146
|
+
res.end(JSON.stringify(body));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function startKynverMcpSseServer(options: StartKynverMcpSseServerOptions): void {
|
|
150
|
+
const port = parseInt(process.env.PORT ?? String(options.defaultPort), 10);
|
|
151
|
+
const baseUrl = process.env.KYNVER_API_URL ?? "https://kynver.com";
|
|
152
|
+
const serviceSecret = process.env.KYNVER_SERVICE_SECRET ?? "";
|
|
153
|
+
const host = resolveListenHost();
|
|
154
|
+
const sessions: Record<string, SessionEntry> = {};
|
|
155
|
+
|
|
156
|
+
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
157
|
+
const url = parseUrl(req, port);
|
|
158
|
+
const path = url.pathname;
|
|
159
|
+
|
|
160
|
+
if (req.method === "GET" && path === "/sse") {
|
|
161
|
+
if (hasForbiddenQueryAuth(url)) {
|
|
162
|
+
json(res, 400, {
|
|
163
|
+
error:
|
|
164
|
+
"Internal auth via query params is not allowed. Use X-Kynver-User-Id and X-Kynver-Service-Secret headers.",
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (url.searchParams.has("apiKey")) {
|
|
170
|
+
console.warn("[MCP SSE] WARNING: apiKey in query string may leak via logs. Prefer Authorization header when your client supports it.");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let userId: string | null = null;
|
|
174
|
+
let auth: SessionAuth | null = null;
|
|
175
|
+
|
|
176
|
+
const internal = serviceSecret ? resolveInternalAuth(req, serviceSecret) : null;
|
|
177
|
+
if (internal) {
|
|
178
|
+
userId = internal.userId;
|
|
179
|
+
auth = internal.auth;
|
|
180
|
+
} else {
|
|
181
|
+
const apiKey = bearerToken(req) || queryApiKey(url);
|
|
182
|
+
if (apiKey) {
|
|
183
|
+
userId = await resolveUserFromApiKey(baseUrl, apiKey);
|
|
184
|
+
if (userId) auth = { kind: "apiKey", apiKey };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!userId || !auth) {
|
|
189
|
+
json(res, 401, {
|
|
190
|
+
error:
|
|
191
|
+
"Unauthorized. Pass API key via Authorization: Bearer <key> or internal X-Kynver-User-Id + X-Kynver-Service-Secret headers.",
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log(`[MCP SSE] New connection (${options.label}, user: ${userId.slice(0, 8)}...)`);
|
|
197
|
+
|
|
198
|
+
const transport = new SSEServerTransport("/messages", res);
|
|
199
|
+
sessions[transport.sessionId] = { transport, userId, auth };
|
|
200
|
+
|
|
201
|
+
res.on("close", () => {
|
|
202
|
+
delete sessions[transport.sessionId];
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const server = await options.createMcpServer();
|
|
206
|
+
await server.connect(transport);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (req.method === "POST" && path === "/messages") {
|
|
211
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
212
|
+
const entry = sessionId ? sessions[sessionId] : undefined;
|
|
213
|
+
|
|
214
|
+
if (!entry) {
|
|
215
|
+
json(res, 400, { error: "No active SSE session for this sessionId" });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!verifyPostAuth(req, url, entry.auth, serviceSecret)) {
|
|
220
|
+
json(res, 401, { error: "Unauthorized. Re-send the same credentials used to open the SSE session." });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const body = await parseJsonBody(req);
|
|
226
|
+
await options.requestContext.run({ userId: entry.userId }, async () => {
|
|
227
|
+
await entry.transport.handlePostMessage(req, res, body);
|
|
228
|
+
});
|
|
229
|
+
} catch (error) {
|
|
230
|
+
json(res, 400, {
|
|
231
|
+
error: error instanceof Error ? error.message : "Invalid request body",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (req.method === "GET" && path === "/health") {
|
|
238
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
239
|
+
res.end("ok");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
json(res, 404, { error: "Not found" });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
httpServer.listen(port, host, () => {
|
|
247
|
+
console.log(`[MCP SSE] ${options.label} listening on ${host}:${port}`);
|
|
248
|
+
});
|
|
249
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates and configures the estimator MCP server with all tools registered.
|
|
3
|
+
* Separated from transport so it can be used with stdio (CLI) or SSE (hosted).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import * as catalogTools from "./tools/catalog-tools.js";
|
|
8
|
+
import * as estimateTools from "./tools/estimate-tools.js";
|
|
9
|
+
import * as templateTools from "./tools/template-tools.js";
|
|
10
|
+
import * as versionTools from "./tools/version-tools.js";
|
|
11
|
+
import * as photoTools from "./tools/photo-tools.js";
|
|
12
|
+
import * as statsPdfTools from "./tools/stats-pdf-tools.js";
|
|
13
|
+
import * as visionTools from "./tools/vision-tools.js";
|
|
14
|
+
import * as companyTools from "./tools/company-tools.js";
|
|
15
|
+
import * as clientTools from "./tools/client-tools.js";
|
|
16
|
+
|
|
17
|
+
type ToolConfig = { description: string; inputSchema: unknown };
|
|
18
|
+
|
|
19
|
+
function toolResult(text: string) {
|
|
20
|
+
return { content: [{ type: "text" as const, text }] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createEstimatorServer(): McpServer {
|
|
24
|
+
const server = new McpServer(
|
|
25
|
+
{ name: "kynver-mcp-estimator", version: "0.1.0" },
|
|
26
|
+
{ capabilities: { tools: {} } }
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function register(
|
|
30
|
+
name: string,
|
|
31
|
+
config: ToolConfig,
|
|
32
|
+
run: (args: unknown) => Promise<string>
|
|
33
|
+
) {
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
server.registerTool(name, config as any, async (args: any) => toolResult(await run(args)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Catalog tools (8) ---
|
|
39
|
+
register(catalogTools.importCatalog.name, { description: catalogTools.importCatalog.description, inputSchema: catalogTools.importCatalog.inputSchemaZod }, (args) => catalogTools.importCatalog.execute(args as any));
|
|
40
|
+
register(catalogTools.reviewImport.name, { description: catalogTools.reviewImport.description, inputSchema: catalogTools.reviewImport.inputSchemaZod }, (args) => catalogTools.reviewImport.execute(args as any));
|
|
41
|
+
register(catalogTools.commitImport.name, { description: catalogTools.commitImport.description, inputSchema: catalogTools.commitImport.inputSchemaZod }, (args) => catalogTools.commitImport.execute(args as any));
|
|
42
|
+
register(catalogTools.setupPrice.name, { description: catalogTools.setupPrice.description, inputSchema: catalogTools.setupPrice.inputSchemaZod }, (args) => catalogTools.setupPrice.execute(args as any));
|
|
43
|
+
register(catalogTools.updatePrice.name, { description: catalogTools.updatePrice.description, inputSchema: catalogTools.updatePrice.inputSchemaZod }, (args) => catalogTools.updatePrice.execute(args as any));
|
|
44
|
+
register(catalogTools.getCatalog.name, { description: catalogTools.getCatalog.description, inputSchema: catalogTools.getCatalog.inputSchemaZod }, (args) => catalogTools.getCatalog.execute(args as any));
|
|
45
|
+
register(catalogTools.getConfig.name, { description: catalogTools.getConfig.description, inputSchema: catalogTools.getConfig.inputSchemaZod }, (args) => catalogTools.getConfig.execute(args as any));
|
|
46
|
+
register(catalogTools.updateConfig.name, { description: catalogTools.updateConfig.description, inputSchema: catalogTools.updateConfig.inputSchemaZod }, (args) => catalogTools.updateConfig.execute(args as any));
|
|
47
|
+
register(catalogTools.importTemplate.name, { description: catalogTools.importTemplate.description, inputSchema: catalogTools.importTemplate.inputSchemaZod }, (args) => catalogTools.importTemplate.execute(args as any));
|
|
48
|
+
|
|
49
|
+
// --- Estimate tools (11) ---
|
|
50
|
+
register(estimateTools.createReplacement.name, { description: estimateTools.createReplacement.description, inputSchema: estimateTools.createReplacement.inputSchemaZod }, (args) => estimateTools.createReplacement.execute(args as any));
|
|
51
|
+
register(estimateTools.createRepair.name, { description: estimateTools.createRepair.description, inputSchema: estimateTools.createRepair.inputSchemaZod }, (args) => estimateTools.createRepair.execute(args as any));
|
|
52
|
+
register(estimateTools.getEstimate.name, { description: estimateTools.getEstimate.description, inputSchema: estimateTools.getEstimate.inputSchemaZod }, (args) => estimateTools.getEstimate.execute(args as any));
|
|
53
|
+
register(estimateTools.listEstimates.name, { description: estimateTools.listEstimates.description, inputSchema: estimateTools.listEstimates.inputSchemaZod }, (args) => estimateTools.listEstimates.execute(args as any));
|
|
54
|
+
register(estimateTools.addLineItem.name, { description: estimateTools.addLineItem.description, inputSchema: estimateTools.addLineItem.inputSchemaZod }, (args) => estimateTools.addLineItem.execute(args as any));
|
|
55
|
+
register(estimateTools.removeLineItem.name, { description: estimateTools.removeLineItem.description, inputSchema: estimateTools.removeLineItem.inputSchemaZod }, (args) => estimateTools.removeLineItem.execute(args as any));
|
|
56
|
+
register(estimateTools.updateLineItem.name, { description: estimateTools.updateLineItem.description, inputSchema: estimateTools.updateLineItem.inputSchemaZod }, (args) => estimateTools.updateLineItem.execute(args as any));
|
|
57
|
+
register(estimateTools.adjustToTarget.name, { description: estimateTools.adjustToTarget.description, inputSchema: estimateTools.adjustToTarget.inputSchemaZod }, (args) => estimateTools.adjustToTarget.execute(args as any));
|
|
58
|
+
register(estimateTools.updateEstimate.name, { description: estimateTools.updateEstimate.description, inputSchema: estimateTools.updateEstimate.inputSchemaZod }, (args) => estimateTools.updateEstimate.execute(args as any));
|
|
59
|
+
register(estimateTools.deleteEstimate.name, { description: estimateTools.deleteEstimate.description, inputSchema: estimateTools.deleteEstimate.inputSchemaZod }, (args) => estimateTools.deleteEstimate.execute(args as any));
|
|
60
|
+
register(estimateTools.duplicateEstimate.name, { description: estimateTools.duplicateEstimate.description, inputSchema: estimateTools.duplicateEstimate.inputSchemaZod }, (args) => estimateTools.duplicateEstimate.execute(args as any));
|
|
61
|
+
|
|
62
|
+
// --- Template tools (4) ---
|
|
63
|
+
register(templateTools.saveTemplate.name, { description: templateTools.saveTemplate.description, inputSchema: templateTools.saveTemplate.inputSchemaZod }, (args) => templateTools.saveTemplate.execute(args as any));
|
|
64
|
+
register(templateTools.listTemplates.name, { description: templateTools.listTemplates.description, inputSchema: templateTools.listTemplates.inputSchemaZod }, (args) => templateTools.listTemplates.execute(args as any));
|
|
65
|
+
register(templateTools.deleteTemplate.name, { description: templateTools.deleteTemplate.description, inputSchema: templateTools.deleteTemplate.inputSchemaZod }, (args) => templateTools.deleteTemplate.execute(args as any));
|
|
66
|
+
register(templateTools.createFromTemplate.name, { description: templateTools.createFromTemplate.description, inputSchema: templateTools.createFromTemplate.inputSchemaZod }, (args) => templateTools.createFromTemplate.execute(args as any));
|
|
67
|
+
|
|
68
|
+
// --- Version tools (2) ---
|
|
69
|
+
register(versionTools.listVersions.name, { description: versionTools.listVersions.description, inputSchema: versionTools.listVersions.inputSchemaZod }, (args) => versionTools.listVersions.execute(args as any));
|
|
70
|
+
register(versionTools.getVersion.name, { description: versionTools.getVersion.description, inputSchema: versionTools.getVersion.inputSchemaZod }, (args) => versionTools.getVersion.execute(args as any));
|
|
71
|
+
|
|
72
|
+
// --- Photo tools (3) ---
|
|
73
|
+
register(photoTools.uploadPhoto.name, { description: photoTools.uploadPhoto.description, inputSchema: photoTools.uploadPhoto.inputSchemaZod }, (args) => photoTools.uploadPhoto.execute(args as any));
|
|
74
|
+
register(photoTools.listPhotos.name, { description: photoTools.listPhotos.description, inputSchema: photoTools.listPhotos.inputSchemaZod }, (args) => photoTools.listPhotos.execute(args as any));
|
|
75
|
+
register(photoTools.deletePhoto.name, { description: photoTools.deletePhoto.description, inputSchema: photoTools.deletePhoto.inputSchemaZod }, (args) => photoTools.deletePhoto.execute(args as any));
|
|
76
|
+
|
|
77
|
+
// --- Stats / PDF tools (3) ---
|
|
78
|
+
register(statsPdfTools.getStats.name, { description: statsPdfTools.getStats.description, inputSchema: statsPdfTools.getStats.inputSchemaZod }, (args) => statsPdfTools.getStats.execute(args as any));
|
|
79
|
+
register(statsPdfTools.exportPdf.name, { description: statsPdfTools.exportPdf.description, inputSchema: statsPdfTools.exportPdf.inputSchemaZod }, (args) => statsPdfTools.exportPdf.execute(args as any));
|
|
80
|
+
register(statsPdfTools.updateOutputConfig.name, { description: statsPdfTools.updateOutputConfig.description, inputSchema: statsPdfTools.updateOutputConfig.inputSchemaZod }, (args) => statsPdfTools.updateOutputConfig.execute(args as any));
|
|
81
|
+
|
|
82
|
+
// --- Vision tools (2) ---
|
|
83
|
+
register(visionTools.analyzePhotos.name, { description: visionTools.analyzePhotos.description, inputSchema: visionTools.analyzePhotos.inputSchemaZod }, (args) => visionTools.analyzePhotos.execute(args as any));
|
|
84
|
+
register(visionTools.analyzeDocument.name, { description: visionTools.analyzeDocument.description, inputSchema: visionTools.analyzeDocument.inputSchemaZod }, (args) => visionTools.analyzeDocument.execute(args as any));
|
|
85
|
+
|
|
86
|
+
// --- Company tools (5) ---
|
|
87
|
+
register(companyTools.createCompany.name, { description: companyTools.createCompany.description, inputSchema: companyTools.createCompany.inputSchemaZod }, (args) => companyTools.createCompany.execute(args as any));
|
|
88
|
+
register(companyTools.listCompanies.name, { description: companyTools.listCompanies.description, inputSchema: companyTools.listCompanies.inputSchemaZod }, (args) => companyTools.listCompanies.execute(args as any));
|
|
89
|
+
register(companyTools.getCompany.name, { description: companyTools.getCompany.description, inputSchema: companyTools.getCompany.inputSchemaZod }, (args) => companyTools.getCompany.execute(args as any));
|
|
90
|
+
register(companyTools.updateCompany.name, { description: companyTools.updateCompany.description, inputSchema: companyTools.updateCompany.inputSchemaZod }, (args) => companyTools.updateCompany.execute(args as any));
|
|
91
|
+
register(companyTools.deleteCompany.name, { description: companyTools.deleteCompany.description, inputSchema: companyTools.deleteCompany.inputSchemaZod }, (args) => companyTools.deleteCompany.execute(args as any));
|
|
92
|
+
|
|
93
|
+
// --- Client tools (5) ---
|
|
94
|
+
register(clientTools.createClient.name, { description: clientTools.createClient.description, inputSchema: clientTools.createClient.inputSchemaZod }, (args) => clientTools.createClient.execute(args as any));
|
|
95
|
+
register(clientTools.listClients.name, { description: clientTools.listClients.description, inputSchema: clientTools.listClients.inputSchemaZod }, (args) => clientTools.listClients.execute(args as any));
|
|
96
|
+
register(clientTools.getClient.name, { description: clientTools.getClient.description, inputSchema: clientTools.getClient.inputSchemaZod }, (args) => clientTools.getClient.execute(args as any));
|
|
97
|
+
register(clientTools.updateClient.name, { description: clientTools.updateClient.description, inputSchema: clientTools.updateClient.inputSchemaZod }, (args) => clientTools.updateClient.execute(args as any));
|
|
98
|
+
register(clientTools.deleteClient.name, { description: clientTools.deleteClient.description, inputSchema: clientTools.deleteClient.inputSchemaZod }, (args) => clientTools.deleteClient.execute(args as any));
|
|
99
|
+
|
|
100
|
+
return server;
|
|
101
|
+
}
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { startKynverMcpSseServer } from "./lib/mcp-sse-server.js";
|
|
2
|
+
import { createEstimatorServer } from "./server.js";
|
|
3
|
+
import { requestContext } from "./lib/kynver-client.js";
|
|
4
|
+
|
|
5
|
+
startKynverMcpSseServer({
|
|
6
|
+
label: "Estimator server",
|
|
7
|
+
defaultPort: 3100,
|
|
8
|
+
createMcpServer: createEstimatorServer,
|
|
9
|
+
requestContext,
|
|
10
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as client from "../lib/kynver-client.js";
|
|
3
|
+
|
|
4
|
+
// --- estimator_import_catalog ---
|
|
5
|
+
|
|
6
|
+
const importCatalogSchema = z.object({
|
|
7
|
+
message: z.string(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const importCatalog = {
|
|
11
|
+
name: "estimator_import_catalog",
|
|
12
|
+
description:
|
|
13
|
+
"Start a catalog import from a file. This is a placeholder — MCP cannot do file uploads. Describe the import request in the message field and the system will guide the user through the upload flow.",
|
|
14
|
+
inputSchemaZod: importCatalogSchema,
|
|
15
|
+
execute: async (args: z.infer<typeof importCatalogSchema>): Promise<string> => {
|
|
16
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
17
|
+
const data = await client.post("/v1/agents/kynver-estimator/chat", {
|
|
18
|
+
message: args.message,
|
|
19
|
+
});
|
|
20
|
+
return JSON.stringify(data);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// --- estimator_review_import ---
|
|
25
|
+
|
|
26
|
+
const reviewImportSchema = z.object({
|
|
27
|
+
importId: z.string(),
|
|
28
|
+
approved: z.boolean().optional(),
|
|
29
|
+
changes: z
|
|
30
|
+
.array(
|
|
31
|
+
z.object({
|
|
32
|
+
index: z.number(),
|
|
33
|
+
action: z.enum(["keep", "skip", "edit"]),
|
|
34
|
+
edits: z.record(z.unknown()).optional(),
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
.optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const reviewImport = {
|
|
41
|
+
name: "estimator_review_import",
|
|
42
|
+
description: "Review a pending catalog import batch before committing. Shows parsed rows with suggested service codes, categories, and pricing. You can approve all, skip individual rows, or edit values before committing. Requires an importId from a previous import.",
|
|
43
|
+
inputSchemaZod: reviewImportSchema,
|
|
44
|
+
execute: async (args: z.infer<typeof reviewImportSchema>): Promise<string> => {
|
|
45
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
46
|
+
const { importId, ...body } = args;
|
|
47
|
+
const data = await client.patch(`/estimator/import/${importId}`, body);
|
|
48
|
+
return JSON.stringify(data);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// --- estimator_commit_import ---
|
|
53
|
+
|
|
54
|
+
const commitImportSchema = z.object({
|
|
55
|
+
importId: z.string(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const commitImport = {
|
|
59
|
+
name: "estimator_commit_import",
|
|
60
|
+
description: "Finalize a reviewed catalog import, writing all approved rows into the user's service catalog. Requires an importId that has been reviewed. This action cannot be undone — rows are added permanently.",
|
|
61
|
+
inputSchemaZod: commitImportSchema,
|
|
62
|
+
execute: async (args: z.infer<typeof commitImportSchema>): Promise<string> => {
|
|
63
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
64
|
+
const data = await client.post(`/estimator/import/${args.importId}/commit`, {});
|
|
65
|
+
return JSON.stringify(data);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// --- estimator_setup_price ---
|
|
70
|
+
|
|
71
|
+
const setupPriceSchema = z.object({
|
|
72
|
+
name: z.string(),
|
|
73
|
+
serviceCode: z.string(),
|
|
74
|
+
category: z.string(),
|
|
75
|
+
materialType: z.string().nullable().optional(),
|
|
76
|
+
unit: z.string(),
|
|
77
|
+
materialCost: z.number().min(0),
|
|
78
|
+
laborCost: z.number().min(0),
|
|
79
|
+
minCharge: z.number().min(0).optional(),
|
|
80
|
+
notes: z.string().optional(),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const setupPrice = {
|
|
84
|
+
name: "estimator_setup_price",
|
|
85
|
+
description: "Add a new service to the user's price book (service catalog). Every service needs: a unique serviceCode (e.g., ROOF_INSPECT, SHINGLE_REPAIR), a display name, category (tear_off, decking, underlayment, field_install, flashing, ridge_cap, ventilation, gutters, cleanup, permit, misc, etc.), unit (sq_ft, linear_ft, each, sheet, roll, bundle, hour, lump), and material/labor costs. Items in the catalog are used when creating estimates.",
|
|
86
|
+
inputSchemaZod: setupPriceSchema,
|
|
87
|
+
execute: async (args: z.infer<typeof setupPriceSchema>): Promise<string> => {
|
|
88
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
89
|
+
const data = await client.post("/estimator/catalog", args);
|
|
90
|
+
return JSON.stringify(data);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// --- estimator_update_price ---
|
|
95
|
+
|
|
96
|
+
const updatePriceSchema = z.object({
|
|
97
|
+
itemId: z.string(),
|
|
98
|
+
name: z.string().optional(),
|
|
99
|
+
serviceCode: z.string().optional(),
|
|
100
|
+
category: z.string().optional(),
|
|
101
|
+
materialType: z.string().nullable().optional(),
|
|
102
|
+
unit: z.string().optional(),
|
|
103
|
+
materialCost: z.number().min(0).optional(),
|
|
104
|
+
laborCost: z.number().min(0).optional(),
|
|
105
|
+
minCharge: z.number().min(0).optional(),
|
|
106
|
+
notes: z.string().optional(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const updatePrice = {
|
|
110
|
+
name: "estimator_update_price",
|
|
111
|
+
description: "Update an existing item in the service catalog. Use estimator_get_catalog first to find the itemId. You can change the name, service code, category, unit, material cost, labor cost, minimum charge, or notes.",
|
|
112
|
+
inputSchemaZod: updatePriceSchema,
|
|
113
|
+
execute: async (args: z.infer<typeof updatePriceSchema>): Promise<string> => {
|
|
114
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
115
|
+
const { itemId, ...body } = args;
|
|
116
|
+
const data = await client.patch(`/estimator/catalog/${itemId}`, body);
|
|
117
|
+
return JSON.stringify(data);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// --- estimator_get_catalog ---
|
|
122
|
+
|
|
123
|
+
const getCatalogSchema = z.object({
|
|
124
|
+
category: z.string().optional(),
|
|
125
|
+
materialType: z.string().optional(),
|
|
126
|
+
limit: z.number().optional(),
|
|
127
|
+
cursor: z.string().optional(),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const getCatalog = {
|
|
131
|
+
name: "estimator_get_catalog",
|
|
132
|
+
description:
|
|
133
|
+
"List all items in the user's service catalog (price book). Returns service codes, names, categories, units, and material/labor costs. IMPORTANT: Always call this before creating estimates to get valid service codes. Optional filters: category (tear_off, decking, underlayment, field_install, ridge_hip, flashing, misc, …) and materialType (asphalt_architectural, asphalt_3tab, metal_standing_seam, tpo, …) must match those exact enum strings — when unsure, omit both to fetch the full catalog.",
|
|
134
|
+
inputSchemaZod: getCatalogSchema,
|
|
135
|
+
execute: async (args: z.infer<typeof getCatalogSchema>): Promise<string> => {
|
|
136
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
137
|
+
|
|
138
|
+
const params = new URLSearchParams();
|
|
139
|
+
if (args.category) params.set("category", args.category);
|
|
140
|
+
if (args.materialType) params.set("materialType", args.materialType);
|
|
141
|
+
if (args.limit != null) params.set("limit", String(args.limit));
|
|
142
|
+
if (args.cursor) params.set("cursor", args.cursor);
|
|
143
|
+
|
|
144
|
+
const q = params.toString();
|
|
145
|
+
const path = q ? `/estimator/catalog?${q}` : "/estimator/catalog";
|
|
146
|
+
|
|
147
|
+
const data = await client.get<unknown>(path);
|
|
148
|
+
return JSON.stringify(data);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// --- estimator_get_config ---
|
|
153
|
+
|
|
154
|
+
const getConfigSchema = z.object({});
|
|
155
|
+
|
|
156
|
+
export const getConfig = {
|
|
157
|
+
name: "estimator_get_config",
|
|
158
|
+
description: "Get the user's estimator settings — company name, default markup/waste percentage, tax rate, currency, contact info, license number, and default notes/terms for estimates.",
|
|
159
|
+
inputSchemaZod: getConfigSchema,
|
|
160
|
+
execute: async (_args: z.infer<typeof getConfigSchema>): Promise<string> => {
|
|
161
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
162
|
+
const data = await client.get<unknown>("/estimator/config");
|
|
163
|
+
return JSON.stringify(data);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// --- estimator_update_config ---
|
|
168
|
+
|
|
169
|
+
const updateConfigSchema = z.object({
|
|
170
|
+
defaultMarkup: z.number().optional(),
|
|
171
|
+
taxRate: z.number().optional(),
|
|
172
|
+
companyName: z.string().optional(),
|
|
173
|
+
companyPhone: z.string().optional(),
|
|
174
|
+
companyEmail: z.string().optional(),
|
|
175
|
+
companyAddress: z.string().optional(),
|
|
176
|
+
companyLogo: z.string().optional(),
|
|
177
|
+
licenseNumber: z.string().optional(),
|
|
178
|
+
defaultNotes: z.string().optional(),
|
|
179
|
+
defaultTerms: z.string().optional(),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const updateConfig = {
|
|
183
|
+
name: "estimator_update_config",
|
|
184
|
+
description: "Update the user's estimator settings. You can set: companyName, defaultMarkup (waste %), taxRate, companyPhone, companyEmail, companyAddress, companyLogo (URL), licenseNumber, defaultNotes (appended to every estimate), and defaultTerms (terms & conditions).",
|
|
185
|
+
inputSchemaZod: updateConfigSchema,
|
|
186
|
+
execute: async (args: z.infer<typeof updateConfigSchema>): Promise<string> => {
|
|
187
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
188
|
+
const data = await client.patch("/estimator/config", args);
|
|
189
|
+
return JSON.stringify(data);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// --- estimator_import_template ---
|
|
194
|
+
|
|
195
|
+
const importTemplateSchema = z.object({
|
|
196
|
+
templateId: z.string().optional(),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export const importTemplate = {
|
|
200
|
+
name: "estimator_import_template",
|
|
201
|
+
description:
|
|
202
|
+
"Import a pre-built pricing catalog template into the user's service catalog. Call without arguments to see available templates (returns id, name, description, item count). Call with a templateId to import all items. These are MATERIAL COSTS ONLY — users should add their own labor rates after import. Items that already exist in the user's catalog are skipped (never overwritten).",
|
|
203
|
+
inputSchemaZod: importTemplateSchema,
|
|
204
|
+
execute: async (args: z.infer<typeof importTemplateSchema>): Promise<string> => {
|
|
205
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
206
|
+
|
|
207
|
+
if (!args.templateId) {
|
|
208
|
+
const data = await client.get("/estimator/catalog/templates");
|
|
209
|
+
return JSON.stringify(data);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const data = await client.post("/estimator/catalog/templates", {
|
|
213
|
+
templateId: args.templateId,
|
|
214
|
+
});
|
|
215
|
+
return JSON.stringify(data);
|
|
216
|
+
},
|
|
217
|
+
};
|