@kynver-app/mcp-estimator 0.2.6 → 0.2.8
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/package.json +1 -1
- package/src/lib/mcp-sse-server.ts +249 -249
- package/src/lib/package-version.ts +55 -55
- package/src/server.ts +3 -0
- package/src/tools/remodel-deliverable-tools.ts +163 -0
package/package.json
CHANGED
|
@@ -1,249 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
// @generated — synced from packages/kynver-mcp-sse-lib/src/package-version.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 { existsSync, readFileSync } from "node:fs";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
|
|
8
|
-
function resolvePackageRoot(moduleUrl: string): string {
|
|
9
|
-
let dir = dirname(fileURLToPath(moduleUrl));
|
|
10
|
-
for (let depth = 0; depth < 6; depth += 1) {
|
|
11
|
-
if (existsSync(join(dir, "package.json"))) return dir;
|
|
12
|
-
const parent = dirname(dir);
|
|
13
|
-
if (parent === dir) break;
|
|
14
|
-
dir = parent;
|
|
15
|
-
}
|
|
16
|
-
throw new Error(`package.json not found above ${dirname(fileURLToPath(moduleUrl))}`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Read a field from the nearest `package.json` above this module. */
|
|
20
|
-
export function readOwnPackageJsonField(
|
|
21
|
-
moduleUrl: string,
|
|
22
|
-
field: "version" | "name",
|
|
23
|
-
): string {
|
|
24
|
-
const pkgPath = join(resolvePackageRoot(moduleUrl), "package.json");
|
|
25
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
|
26
|
-
const value = pkg[field];
|
|
27
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
28
|
-
throw new Error(`Missing package.json ${field} at ${pkgPath}`);
|
|
29
|
-
}
|
|
30
|
-
return value;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function readOwnPackageVersion(moduleUrl: string = import.meta.url): string {
|
|
34
|
-
return readOwnPackageJsonField(moduleUrl, "version");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function wantsCliVersion(argv: string[]): boolean {
|
|
38
|
-
return argv.some((arg) => arg === "--version" || arg === "-v");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function printCliVersionAndExit(version: string, binName?: string): never {
|
|
42
|
-
console.log(binName ? `${binName} ${version}` : version);
|
|
43
|
-
process.exit(0);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** When `--version` / `-v` is present, print package version and exit before side effects. */
|
|
47
|
-
export function handleCliVersionFlag(
|
|
48
|
-
argv: string[],
|
|
49
|
-
moduleUrl: string = import.meta.url,
|
|
50
|
-
binName?: string,
|
|
51
|
-
): boolean {
|
|
52
|
-
if (!wantsCliVersion(argv)) return false;
|
|
53
|
-
printCliVersionAndExit(readOwnPackageVersion(moduleUrl), binName);
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
1
|
+
// @generated — synced from packages/kynver-mcp-sse-lib/src/package-version.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 { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
function resolvePackageRoot(moduleUrl: string): string {
|
|
9
|
+
let dir = dirname(fileURLToPath(moduleUrl));
|
|
10
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
11
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
12
|
+
const parent = dirname(dir);
|
|
13
|
+
if (parent === dir) break;
|
|
14
|
+
dir = parent;
|
|
15
|
+
}
|
|
16
|
+
throw new Error(`package.json not found above ${dirname(fileURLToPath(moduleUrl))}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Read a field from the nearest `package.json` above this module. */
|
|
20
|
+
export function readOwnPackageJsonField(
|
|
21
|
+
moduleUrl: string,
|
|
22
|
+
field: "version" | "name",
|
|
23
|
+
): string {
|
|
24
|
+
const pkgPath = join(resolvePackageRoot(moduleUrl), "package.json");
|
|
25
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
|
26
|
+
const value = pkg[field];
|
|
27
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
28
|
+
throw new Error(`Missing package.json ${field} at ${pkgPath}`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readOwnPackageVersion(moduleUrl: string = import.meta.url): string {
|
|
34
|
+
return readOwnPackageJsonField(moduleUrl, "version");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function wantsCliVersion(argv: string[]): boolean {
|
|
38
|
+
return argv.some((arg) => arg === "--version" || arg === "-v");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function printCliVersionAndExit(version: string, binName?: string): never {
|
|
42
|
+
console.log(binName ? `${binName} ${version}` : version);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** When `--version` / `-v` is present, print package version and exit before side effects. */
|
|
47
|
+
export function handleCliVersionFlag(
|
|
48
|
+
argv: string[],
|
|
49
|
+
moduleUrl: string = import.meta.url,
|
|
50
|
+
binName?: string,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (!wantsCliVersion(argv)) return false;
|
|
53
|
+
printCliVersionAndExit(readOwnPackageVersion(moduleUrl), binName);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -18,6 +18,7 @@ import * as clientTools from "./tools/client-tools.js";
|
|
|
18
18
|
import * as takeoffTools from "./tools/takeoff-tools.js";
|
|
19
19
|
import * as intakeTools from "./tools/intake-tools.js";
|
|
20
20
|
import * as remodelEstimateTools from "./tools/remodel-estimate-tools.js";
|
|
21
|
+
import * as remodelDeliverableTools from "./tools/remodel-deliverable-tools.js";
|
|
21
22
|
import { readOwnPackageVersion } from "./lib/package-version.js";
|
|
22
23
|
|
|
23
24
|
const PACKAGE_VERSION = readOwnPackageVersion(import.meta.url);
|
|
@@ -73,6 +74,8 @@ export function createEstimatorServer(): McpServer {
|
|
|
73
74
|
register(estimateTools.deleteEstimate.name, { description: estimateTools.deleteEstimate.description, inputSchema: estimateTools.deleteEstimate.inputSchemaZod }, (args) => estimateTools.deleteEstimate.execute(args as any));
|
|
74
75
|
register(estimateTools.duplicateEstimate.name, { description: estimateTools.duplicateEstimate.description, inputSchema: estimateTools.duplicateEstimate.inputSchemaZod }, (args) => estimateTools.duplicateEstimate.execute(args as any));
|
|
75
76
|
register(remodelEstimateTools.buildRemodelEstimate.name, { description: remodelEstimateTools.buildRemodelEstimate.description, inputSchema: remodelEstimateTools.buildRemodelEstimate.inputSchemaZod }, (args) => remodelEstimateTools.buildRemodelEstimate.execute(args as any));
|
|
77
|
+
register(remodelDeliverableTools.buildRemodelDeliverable.name, { description: remodelDeliverableTools.buildRemodelDeliverable.description, inputSchema: remodelDeliverableTools.buildRemodelDeliverable.inputSchemaZod }, (args) => remodelDeliverableTools.buildRemodelDeliverable.execute(args as any));
|
|
78
|
+
register(remodelDeliverableTools.draftRemodelDeliverable.name, { description: remodelDeliverableTools.draftRemodelDeliverable.description, inputSchema: remodelDeliverableTools.draftRemodelDeliverable.inputSchemaZod }, (args) => remodelDeliverableTools.draftRemodelDeliverable.execute(args as any));
|
|
76
79
|
|
|
77
80
|
// --- Template tools (4) ---
|
|
78
81
|
register(templateTools.saveTemplate.name, { description: templateTools.saveTemplate.description, inputSchema: templateTools.saveTemplate.inputSchemaZod }, (args) => templateTools.saveTemplate.execute(args as any));
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as client from "../lib/kynver-client.js";
|
|
3
|
+
|
|
4
|
+
const packageSchema = z.object({
|
|
5
|
+
intake: z.object({
|
|
6
|
+
projectName: z.string().optional(),
|
|
7
|
+
scopes: z.array(
|
|
8
|
+
z.object({
|
|
9
|
+
key: z.string(),
|
|
10
|
+
label: z.string().optional(),
|
|
11
|
+
quantity: z.number().optional(),
|
|
12
|
+
unit: z.string().optional(),
|
|
13
|
+
serviceCode: z.string().optional(),
|
|
14
|
+
budgetLump: z.number().optional(),
|
|
15
|
+
notes: z.string().optional(),
|
|
16
|
+
})
|
|
17
|
+
),
|
|
18
|
+
expandScopeComponents: z.boolean().optional(),
|
|
19
|
+
includeStandardAlternates: z.boolean().optional(),
|
|
20
|
+
exclusions: z.array(z.string()).optional(),
|
|
21
|
+
assumptions: z.array(z.string()).optional(),
|
|
22
|
+
notes: z.string().optional(),
|
|
23
|
+
}),
|
|
24
|
+
pricing: z
|
|
25
|
+
.object({
|
|
26
|
+
contingencyPercent: z.number().optional(),
|
|
27
|
+
markupPercent: z.number().optional(),
|
|
28
|
+
profitPercent: z.number().optional(),
|
|
29
|
+
})
|
|
30
|
+
.optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const buildDeliverableSchema = z.object({
|
|
34
|
+
preset: z.enum(["matthew-portland-2026-06"]).optional(),
|
|
35
|
+
client: z.object({
|
|
36
|
+
projectTitle: z.string(),
|
|
37
|
+
clientName: z.string().optional(),
|
|
38
|
+
preparedBy: z.string().optional(),
|
|
39
|
+
location: z.string().optional(),
|
|
40
|
+
documentType: z.string().optional(),
|
|
41
|
+
validityNote: z.string().optional(),
|
|
42
|
+
}),
|
|
43
|
+
budgetPackage: packageSchema.optional(),
|
|
44
|
+
fullPackage: packageSchema.optional(),
|
|
45
|
+
alternates: z
|
|
46
|
+
.array(
|
|
47
|
+
z.object({
|
|
48
|
+
key: z.string(),
|
|
49
|
+
label: z.string(),
|
|
50
|
+
description: z.string(),
|
|
51
|
+
budgetPlaceholder: z.number().nullable(),
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
.optional(),
|
|
55
|
+
correctionNote: z.string().optional(),
|
|
56
|
+
scopeBasis: z.array(z.string()).optional(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const buildRemodelDeliverable = {
|
|
60
|
+
name: "estimator_build_remodel_deliverable",
|
|
61
|
+
description:
|
|
62
|
+
"Build a dogfoodable residential remodel deliverable package with distinct budget/base and full/dream scope sections, separate alternates (never in headline totals), client-facing line-item descriptions, HTML/PDF handoff metadata, and MCP tool guidance for deeper detail. Use preset matthew-portland-2026-06 for the Matthew Portland fixture ($339,025 full / $167,200 bank-budget). Does not persist — use handoff JSON + reviewMarkdown to render client PDF/HTML. Supersedes hand-built deliverables that omitted structured sections. For single-scope preview only, use estimator_build_remodel_estimate. Prefer estimator_draft_remodel_deliverable when you have scope notes and want the tool to assemble the budget/full intakes for you.",
|
|
63
|
+
inputSchemaZod: buildDeliverableSchema,
|
|
64
|
+
execute: async (args: z.infer<typeof buildDeliverableSchema>): Promise<string> => {
|
|
65
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
66
|
+
const data = await client.post(
|
|
67
|
+
"/estimator/estimates/build-remodel-deliverable",
|
|
68
|
+
args
|
|
69
|
+
);
|
|
70
|
+
return JSON.stringify(data);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const draftScopeSchema = z.object({
|
|
75
|
+
key: z.string().min(1),
|
|
76
|
+
label: z.string().optional(),
|
|
77
|
+
quantity: z.number().positive().optional(),
|
|
78
|
+
unit: z.string().optional(),
|
|
79
|
+
serviceCode: z.string().optional(),
|
|
80
|
+
budgetLump: z.number().nonnegative().optional(),
|
|
81
|
+
notes: z.string().optional(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const draftPricingSchema = z
|
|
85
|
+
.object({
|
|
86
|
+
contingencyPercent: z.number().optional(),
|
|
87
|
+
markupPercent: z.number().optional(),
|
|
88
|
+
profitPercent: z.number().optional(),
|
|
89
|
+
})
|
|
90
|
+
.optional();
|
|
91
|
+
|
|
92
|
+
const draftScopeBundleSchema = z.object({
|
|
93
|
+
label: z.string().optional(),
|
|
94
|
+
whatsIncluded: z.string().optional(),
|
|
95
|
+
projectName: z.string().optional(),
|
|
96
|
+
scopes: z.array(draftScopeSchema),
|
|
97
|
+
assumptions: z.array(z.string()).optional(),
|
|
98
|
+
exclusions: z.array(z.string()).optional(),
|
|
99
|
+
notes: z.string().optional(),
|
|
100
|
+
expandScopeComponents: z.boolean().optional(),
|
|
101
|
+
pricing: draftPricingSchema,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const draftBudgetBundleSchema = draftScopeBundleSchema.extend({
|
|
105
|
+
derivedFromFull: z
|
|
106
|
+
.object({
|
|
107
|
+
omitFromFullKeys: z.array(z.string()).optional(),
|
|
108
|
+
replaceWithAllowanceLump: z
|
|
109
|
+
.array(
|
|
110
|
+
z.object({
|
|
111
|
+
key: z.string().min(1),
|
|
112
|
+
label: z.string().optional(),
|
|
113
|
+
budgetLump: z.number().nonnegative(),
|
|
114
|
+
notes: z.string().optional(),
|
|
115
|
+
})
|
|
116
|
+
)
|
|
117
|
+
.optional(),
|
|
118
|
+
additionalScopes: z.array(draftScopeSchema).optional(),
|
|
119
|
+
})
|
|
120
|
+
.optional(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const draftDeliverableSchema = z.object({
|
|
124
|
+
client: z.object({
|
|
125
|
+
projectTitle: z.string().min(1),
|
|
126
|
+
clientName: z.string().optional(),
|
|
127
|
+
preparedBy: z.string().optional(),
|
|
128
|
+
location: z.string().optional(),
|
|
129
|
+
documentType: z.string().optional(),
|
|
130
|
+
validityNote: z.string().optional(),
|
|
131
|
+
}),
|
|
132
|
+
fullScope: draftScopeBundleSchema,
|
|
133
|
+
budgetScope: draftBudgetBundleSchema.optional(),
|
|
134
|
+
alternates: z
|
|
135
|
+
.array(
|
|
136
|
+
z.object({
|
|
137
|
+
key: z.string(),
|
|
138
|
+
label: z.string(),
|
|
139
|
+
description: z.string(),
|
|
140
|
+
budgetPlaceholder: z.number().nullable(),
|
|
141
|
+
})
|
|
142
|
+
)
|
|
143
|
+
.optional(),
|
|
144
|
+
correctionNote: z.string().optional(),
|
|
145
|
+
scopeBasis: z.array(z.string()).optional(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export const draftRemodelDeliverable = {
|
|
149
|
+
name: "estimator_draft_remodel_deliverable",
|
|
150
|
+
description:
|
|
151
|
+
"Draft a sendable residential remodel deliverable package from scope notes in a single call. Provide one fullScope.scopes list (each scope is a key like 'kitchen', 'bath_primary', 'drywall', or a custom slug, plus optional budgetLump/notes — the built-in catalog supplies label/unit/default rate). The budget package can be (a) provided explicitly via budgetScope.scopes, (b) derived from full via budgetScope.derivedFromFull.omitFromFullKeys (drop scopes from full), and/or budgetScope.derivedFromFull.replaceWithAllowanceLump (collapse component-detail rooms into single allowance lines), or (c) omitted to mirror full as collapsed allowances. Returns the full RemodelDeliverablePackage including budgetScope, fullScope, alternates, allowances, assumptions, exclusions, handoff metadata, reviewMarkdown (agent-facing), and clientMarkdown (sendable). Does not persist. Use this for fresh remodel intakes instead of estimator_build_remodel_deliverable when you have not yet authored both intakes by hand.",
|
|
152
|
+
inputSchemaZod: draftDeliverableSchema,
|
|
153
|
+
execute: async (
|
|
154
|
+
args: z.infer<typeof draftDeliverableSchema>
|
|
155
|
+
): Promise<string> => {
|
|
156
|
+
if (!client.hasApiKey()) return client.noApiKeyMessage();
|
|
157
|
+
const data = await client.post(
|
|
158
|
+
"/estimator/estimates/draft-remodel-deliverable",
|
|
159
|
+
args
|
|
160
|
+
);
|
|
161
|
+
return JSON.stringify(data);
|
|
162
|
+
},
|
|
163
|
+
};
|