product-spec-mcp 0.3.21 → 0.3.26

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.
@@ -0,0 +1,58 @@
1
+ # Online PM Gate
2
+
3
+ P0 online gate is an HTTP classifier for low-confidence or conflicting local PM Gate decisions. It does not generate long specs. It only helps choose the gate and returns short JSON for the local MCP package to validate and merge.
4
+
5
+ ## Local MCP Environment
6
+
7
+ ```bash
8
+ PRODUCT_SPEC_REMOTE_GATE_URL=https://gate.example.com/v1/pm-intent
9
+ PRODUCT_SPEC_REMOTE_GATE_TOKEN=replace-with-token
10
+ PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS=2500
11
+ PRODUCT_SPEC_REMOTE_GATE_MODE=auto
12
+ PRODUCT_SPEC_TELEMETRY=off
13
+ ```
14
+
15
+ Modes:
16
+
17
+ - `auto`: only call remote when local gate is low confidence, unknown, or internally conflicting.
18
+ - `off`: never call remote.
19
+ - `force`: call remote for debugging.
20
+
21
+ Telemetry:
22
+
23
+ - `off`: do not store prompt samples.
24
+ - `minimal`: store hashes and decisions only.
25
+ - `sample`: store redacted prompt samples.
26
+
27
+ ## Cloudflare Worker
28
+
29
+ Files:
30
+
31
+ - `workers/pm-intent-gate.mjs`
32
+ - `workers/schema.sql`
33
+ - `workers/wrangler.toml.example`
34
+
35
+ Setup outline:
36
+
37
+ ```bash
38
+ cd workers
39
+ cp wrangler.toml.example wrangler.toml
40
+ wrangler kv namespace create PROMPT_CACHE
41
+ wrangler d1 create product-spec-prompt-samples
42
+ wrangler d1 execute product-spec-prompt-samples --file schema.sql
43
+ wrangler secret put GATE_TOKEN
44
+ wrangler secret put DEEPSEEK_API_KEY
45
+ wrangler secret put RATE_LIMIT_SALT
46
+ wrangler deploy
47
+ ```
48
+
49
+ Runtime behavior:
50
+
51
+ - Prompt cache key: `cache:{model}:{promptHash}:pm-gate-v1`
52
+ - Cache TTL: 7 days
53
+ - LLM quota: 3 non-cached LLM decisions per IP per Shanghai calendar day
54
+ - User message sent to LLM: max 500 characters
55
+ - LLM max output tokens: 600
56
+ - LLM temperature: 0.1
57
+
58
+ If the Worker is unreachable, rate-limited, returns invalid JSON, or returns invalid enum fields, the local MCP falls back to the local PM Gate decision.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-spec-mcp",
3
- "version": "0.3.21",
3
+ "version": "0.3.26",
4
4
  "description": "MCP Server for product specification - requirement interrogation, architecture decision, UI translation, debug guidance, and acceptance generation",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.cjs",
@@ -9,7 +9,11 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/index.cjs",
12
- "README.md"
12
+ "README.md",
13
+ "docs/online-pm-gate.md",
14
+ "workers/pm-intent-gate.mjs",
15
+ "workers/schema.sql",
16
+ "workers/wrangler.toml.example"
13
17
  ],
14
18
  "engines": {
15
19
  "node": ">=18"
@@ -0,0 +1,383 @@
1
+ const GATE_SCHEMA_VERSION = "pm-gate-v1";
2
+ const DEFAULT_MODEL = "deepseek-chat";
3
+ const DAILY_LIMIT = 3;
4
+
5
+ export default {
6
+ async fetch(request, env) {
7
+ const url = new URL(request.url);
8
+ if (request.method === "GET" && url.pathname === "/health") {
9
+ return json({ ok: true, gateSchemaVersion: GATE_SCHEMA_VERSION });
10
+ }
11
+ if (request.method !== "POST" || url.pathname !== "/v1/pm-intent") {
12
+ return json({ error: "not_found" }, 404);
13
+ }
14
+ if (!isAuthorized(request, env)) {
15
+ return json({ error: "unauthorized" }, 401);
16
+ }
17
+
18
+ let body;
19
+ try {
20
+ body = await request.json();
21
+ } catch {
22
+ return json({ error: "invalid_json" }, 400);
23
+ }
24
+
25
+ const telemetryMode = normalizeTelemetry(request.headers.get("x-product-spec-telemetry") || "off");
26
+ const message = String(body.message || "").slice(0, 500);
27
+ const messageHash = body.messageHash || await sha256(normalizeText(message));
28
+ const model = env.DEEPSEEK_MODEL || DEFAULT_MODEL;
29
+ const cacheKey = `cache:${model}:${messageHash}:${GATE_SCHEMA_VERSION}`;
30
+ const cached = await env.PROMPT_CACHE?.get(cacheKey, "json");
31
+ const ipKey = await rateLimitKey(request, env);
32
+ const resetAt = nextShanghaiMidnightIso();
33
+
34
+ if (cached?.decision) {
35
+ await maybeStoreSample(env, telemetryMode, body, cached.decision, cached.decision, {
36
+ llmUsed: 0,
37
+ cacheHit: 1,
38
+ rateLimitStatus: "cache_hit",
39
+ });
40
+ return json({
41
+ decision: cached.decision,
42
+ llmGate: {
43
+ used: false,
44
+ provider: "deepseek",
45
+ model,
46
+ promptTokensApprox: cached.promptTokensApprox || 0,
47
+ completionTokensApprox: cached.completionTokensApprox || 0,
48
+ cacheHit: true,
49
+ },
50
+ rateLimit: {
51
+ limit: DAILY_LIMIT,
52
+ remaining: await remainingForKey(env, ipKey),
53
+ resetAt,
54
+ },
55
+ privacy: privacyResult(telemetryMode),
56
+ });
57
+ }
58
+
59
+ const limit = await consumeLimit(env, ipKey, resetAt);
60
+ if (!limit.allowed) {
61
+ await maybeStoreSample(env, telemetryMode, body, null, body.ruleDecision || {}, {
62
+ llmUsed: 0,
63
+ cacheHit: 0,
64
+ rateLimitStatus: "limited",
65
+ fallbackReason: "rate_limited",
66
+ });
67
+ return json({
68
+ decision: fallbackDecision(body.ruleDecision),
69
+ llmGate: { used: false, provider: "deepseek", model, cacheHit: false },
70
+ rateLimit: { limit: DAILY_LIMIT, remaining: 0, resetAt },
71
+ privacy: privacyResult(telemetryMode),
72
+ }, 429);
73
+ }
74
+
75
+ const prompt = buildGatePrompt(message, body.ruleDecision || {}, body.choices || {});
76
+ const promptTokensApprox = approxTokens(prompt);
77
+
78
+ let llmDecision;
79
+ let completionTokensApprox = 0;
80
+ let fallbackReason = "";
81
+ try {
82
+ const llmText = await callDeepSeek(env, model, prompt);
83
+ completionTokensApprox = approxTokens(llmText);
84
+ llmDecision = sanitizeDecision(extractJson(llmText));
85
+ if (!llmDecision) fallbackReason = "invalid_llm_schema";
86
+ } catch (error) {
87
+ fallbackReason = error instanceof Error ? error.message : "llm_error";
88
+ }
89
+
90
+ const finalDecision = llmDecision || fallbackDecision(body.ruleDecision);
91
+ if (llmDecision && env.PROMPT_CACHE) {
92
+ await env.PROMPT_CACHE.put(cacheKey, JSON.stringify({
93
+ decision: finalDecision,
94
+ promptTokensApprox,
95
+ completionTokensApprox,
96
+ }), { expirationTtl: 7 * 24 * 60 * 60 });
97
+ }
98
+
99
+ await maybeStoreSample(env, telemetryMode, body, llmDecision, finalDecision, {
100
+ llmUsed: llmDecision ? 1 : 0,
101
+ cacheHit: 0,
102
+ promptTokensApprox,
103
+ completionTokensApprox,
104
+ rateLimitStatus: "allowed",
105
+ fallbackReason,
106
+ });
107
+
108
+ return json({
109
+ decision: finalDecision,
110
+ llmGate: {
111
+ used: Boolean(llmDecision),
112
+ provider: "deepseek",
113
+ model,
114
+ promptTokensApprox,
115
+ completionTokensApprox,
116
+ cacheHit: false,
117
+ ...(fallbackReason ? { fallbackReason } : {}),
118
+ },
119
+ rateLimit: {
120
+ limit: DAILY_LIMIT,
121
+ remaining: limit.remaining,
122
+ resetAt,
123
+ },
124
+ privacy: privacyResult(telemetryMode),
125
+ });
126
+ },
127
+ };
128
+
129
+ function isAuthorized(request, env) {
130
+ if (!env.GATE_TOKEN) return false;
131
+ return request.headers.get("authorization") === `Bearer ${env.GATE_TOKEN}`;
132
+ }
133
+
134
+ function buildGatePrompt(message, rule, choices) {
135
+ return JSON.stringify({
136
+ task: "Choose the best PM gate only. Return strict JSON, no markdown.",
137
+ output: {
138
+ bestGate: "one needType enum",
139
+ usageScope: "one usageScope enum",
140
+ maintenanceMode: "one maintenanceMode enum",
141
+ accessTopology: "one accessTopology enum",
142
+ confidence: "high|medium|low",
143
+ strongSignals: ["short strings"],
144
+ weakSignals: ["short strings"],
145
+ coreObjects: ["short strings"],
146
+ states: ["short strings"],
147
+ actions: ["short strings"],
148
+ mustNotUse: ["short ids"],
149
+ boundaryQuestionIds: ["short ids"],
150
+ },
151
+ msg: message,
152
+ rule: {
153
+ strong: rule.strongSignals || [],
154
+ weak: rule.weakSignals || [],
155
+ shape: rule.technicalShape || rule.shape || "unknown",
156
+ conflict: Boolean(rule.conflict),
157
+ },
158
+ choices,
159
+ });
160
+ }
161
+
162
+ async function callDeepSeek(env, model, prompt) {
163
+ if (!env.DEEPSEEK_API_KEY) throw new Error("missing_deepseek_api_key");
164
+ const response = await fetch("https://api.deepseek.com/chat/completions", {
165
+ method: "POST",
166
+ headers: {
167
+ "content-type": "application/json",
168
+ authorization: `Bearer ${env.DEEPSEEK_API_KEY}`,
169
+ },
170
+ body: JSON.stringify({
171
+ model,
172
+ temperature: 0.1,
173
+ max_tokens: 600,
174
+ messages: [
175
+ { role: "system", content: "You are a terse product intent classifier. Output JSON only." },
176
+ { role: "user", content: prompt },
177
+ ],
178
+ }),
179
+ });
180
+ if (!response.ok) throw new Error(`deepseek_http_${response.status}`);
181
+ const data = await response.json();
182
+ const content = data?.choices?.[0]?.message?.content;
183
+ if (typeof content !== "string" || !content.trim()) throw new Error("deepseek_empty_content");
184
+ return content;
185
+ }
186
+
187
+ function extractJson(text) {
188
+ try {
189
+ return JSON.parse(text);
190
+ } catch {
191
+ const start = text.indexOf("{");
192
+ const end = text.lastIndexOf("}");
193
+ if (start < 0 || end <= start) return null;
194
+ try {
195
+ return JSON.parse(text.slice(start, end + 1));
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+ }
201
+
202
+ function sanitizeDecision(raw) {
203
+ if (!raw || typeof raw !== "object") return null;
204
+ const bestGate = raw.bestGate || raw.needType;
205
+ if (!needTypes.includes(bestGate)) return null;
206
+ const decision = { bestGate };
207
+ copyEnum(raw, decision, "usageScope", usageScopes);
208
+ copyEnum(raw, decision, "maintenanceMode", maintenanceModes);
209
+ copyEnum(raw, decision, "accessTopology", accessTopologies);
210
+ copyEnum(raw, decision, "confidence", confidences);
211
+ copyStringArray(raw, decision, "strongSignals");
212
+ copyStringArray(raw, decision, "weakSignals");
213
+ copyStringArray(raw, decision, "coreObjects");
214
+ copyStringArray(raw, decision, "states");
215
+ copyStringArray(raw, decision, "actions");
216
+ copyStringArray(raw, decision, "mustNotUse");
217
+ copyStringArray(raw, decision, "boundaryQuestionIds");
218
+ return decision;
219
+ }
220
+
221
+ function fallbackDecision(ruleDecision) {
222
+ return {
223
+ bestGate: ruleDecision?.needType || "unknown",
224
+ usageScope: ruleDecision?.usageScope || "unknown",
225
+ maintenanceMode: ruleDecision?.maintenanceMode || "unknown",
226
+ accessTopology: ruleDecision?.accessTopology || "unknown",
227
+ confidence: "low",
228
+ strongSignals: ruleDecision?.strongSignals || [],
229
+ weakSignals: ruleDecision?.weakSignals || [],
230
+ coreObjects: [],
231
+ states: [],
232
+ actions: [],
233
+ mustNotUse: ruleDecision?.mustNotUse || [],
234
+ boundaryQuestionIds: ruleDecision?.boundaryQuestionIds || ["usage_scope", "maintenance_mode", "data_flow"],
235
+ };
236
+ }
237
+
238
+ async function consumeLimit(env, key, resetAt) {
239
+ if (!env.PROMPT_CACHE) return { allowed: true, remaining: DAILY_LIMIT - 1 };
240
+ const current = Number(await env.PROMPT_CACHE.get(key) || "0");
241
+ if (current >= DAILY_LIMIT) return { allowed: false, remaining: 0 };
242
+ const next = current + 1;
243
+ const resetSeconds = Math.max(60, Math.floor((new Date(resetAt).getTime() - Date.now()) / 1000));
244
+ await env.PROMPT_CACHE.put(key, String(next), { expirationTtl: resetSeconds });
245
+ return { allowed: true, remaining: Math.max(0, DAILY_LIMIT - next) };
246
+ }
247
+
248
+ async function remainingForKey(env, key) {
249
+ if (!env.PROMPT_CACHE) return DAILY_LIMIT;
250
+ const current = Number(await env.PROMPT_CACHE.get(key) || "0");
251
+ return Math.max(0, DAILY_LIMIT - current);
252
+ }
253
+
254
+ async function rateLimitKey(request, env) {
255
+ const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for") || "unknown";
256
+ const day = shanghaiDateKey();
257
+ const salt = env.RATE_LIMIT_SALT || "product-spec";
258
+ return `rate:${day}:${await sha256(`${salt}:${ip}`)}`;
259
+ }
260
+
261
+ async function maybeStoreSample(env, telemetryMode, body, llmDecision, finalDecision, meta) {
262
+ if (!env.PROMPT_SAMPLES || telemetryMode === "off") return;
263
+ const id = crypto.randomUUID();
264
+ const message = String(body.message || "").slice(0, 500);
265
+ const messageHash = body.messageHash || await sha256(normalizeText(message));
266
+ const sample = telemetryMode === "sample" ? redact(message) : null;
267
+ await env.PROMPT_SAMPLES.prepare(
268
+ `INSERT INTO prompt_samples (
269
+ id, created_at, package_version, client, telemetry_mode, message_hash, message_sample,
270
+ rule_decision_json, llm_decision_json, final_decision_json, llm_used, cache_hit,
271
+ prompt_tokens_approx, completion_tokens_approx, rate_limit_status, fallback_reason
272
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
273
+ ).bind(
274
+ id,
275
+ new Date().toISOString(),
276
+ body.packageVersion || null,
277
+ body.client || null,
278
+ telemetryMode,
279
+ messageHash,
280
+ sample,
281
+ JSON.stringify(body.ruleDecision || {}),
282
+ llmDecision ? JSON.stringify(llmDecision) : null,
283
+ JSON.stringify(finalDecision || {}),
284
+ meta.llmUsed,
285
+ meta.cacheHit,
286
+ meta.promptTokensApprox || null,
287
+ meta.completionTokensApprox || null,
288
+ meta.rateLimitStatus || null,
289
+ meta.fallbackReason || null
290
+ ).run();
291
+ }
292
+
293
+ function normalizeTelemetry(value) {
294
+ return ["off", "minimal", "sample"].includes(value) ? value : "off";
295
+ }
296
+
297
+ function privacyResult(telemetryMode) {
298
+ return {
299
+ stored: telemetryMode !== "off",
300
+ mode: telemetryMode,
301
+ redacted: telemetryMode === "sample",
302
+ };
303
+ }
304
+
305
+ function redact(text) {
306
+ return text
307
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[EMAIL]")
308
+ .replace(/(?<!\d)1[3-9]\d{9}(?!\d)/g, "[PHONE]")
309
+ .replace(/\b\d{15,19}\b/g, "[SENSITIVE_NUMBER]")
310
+ .replace(/\b(?:sk|pk|api|token|secret)[-_]?[A-Za-z0-9]{16,}\b/gi, "[SECRET]")
311
+ .replace(/([?&](?:token|key|secret|access_token)=)[^&\s]+/gi, "$1[SECRET]");
312
+ }
313
+
314
+ function shanghaiDateKey() {
315
+ return new Intl.DateTimeFormat("en-CA", {
316
+ timeZone: "Asia/Shanghai",
317
+ year: "numeric",
318
+ month: "2-digit",
319
+ day: "2-digit",
320
+ }).format(new Date());
321
+ }
322
+
323
+ function nextShanghaiMidnightIso() {
324
+ const parts = new Intl.DateTimeFormat("en-CA", {
325
+ timeZone: "Asia/Shanghai",
326
+ year: "numeric",
327
+ month: "2-digit",
328
+ day: "2-digit",
329
+ }).formatToParts(new Date());
330
+ const year = Number(parts.find((p) => p.type === "year").value);
331
+ const month = Number(parts.find((p) => p.type === "month").value);
332
+ const day = Number(parts.find((p) => p.type === "day").value);
333
+ const next = new Date(Date.UTC(year, month - 1, day + 1));
334
+ const y = next.getUTCFullYear();
335
+ const m = String(next.getUTCMonth() + 1).padStart(2, "0");
336
+ const d = String(next.getUTCDate()).padStart(2, "0");
337
+ return `${y}-${m}-${d}T00:00:00+08:00`;
338
+ }
339
+
340
+ async function sha256(text) {
341
+ const bytes = new TextEncoder().encode(text);
342
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
343
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
344
+ }
345
+
346
+ function normalizeText(text) {
347
+ return text.trim().replace(/\s+/g, " ");
348
+ }
349
+
350
+ function approxTokens(text) {
351
+ return Math.ceil(String(text || "").length / 4);
352
+ }
353
+
354
+ function copyEnum(raw, decision, key, allowed) {
355
+ if (raw[key] !== undefined && allowed.includes(raw[key])) decision[key] = raw[key];
356
+ }
357
+
358
+ function copyStringArray(raw, decision, key) {
359
+ if (Array.isArray(raw[key])) decision[key] = raw[key].filter((item) => typeof item === "string").slice(0, 12);
360
+ }
361
+
362
+ function json(payload, status = 200) {
363
+ return new Response(JSON.stringify(payload), {
364
+ status,
365
+ headers: { "content-type": "application/json; charset=utf-8" },
366
+ });
367
+ }
368
+
369
+ const needTypes = [
370
+ "static_display",
371
+ "personal_local_tool",
372
+ "multi_user_collaboration",
373
+ "content_marketing_site",
374
+ "data_visualization_site",
375
+ "transaction_workflow",
376
+ "content_knowledge",
377
+ "ai_automation",
378
+ "unknown",
379
+ ];
380
+ const usageScopes = ["self", "fixed_group", "public_audience", "unknown"];
381
+ const maintenanceModes = ["agent_assisted", "manual_files", "web_admin", "visitor_submission", "runtime_collaboration", "unknown"];
382
+ const accessTopologies = ["single_device", "lan_only", "internet_ip", "public_domain", "unknown"];
383
+ const confidences = ["high", "medium", "low"];
@@ -0,0 +1,24 @@
1
+ CREATE TABLE IF NOT EXISTS prompt_samples (
2
+ id TEXT PRIMARY KEY,
3
+ created_at TEXT NOT NULL,
4
+ package_version TEXT,
5
+ client TEXT,
6
+ telemetry_mode TEXT NOT NULL,
7
+ message_hash TEXT NOT NULL,
8
+ message_sample TEXT,
9
+ rule_decision_json TEXT NOT NULL,
10
+ llm_decision_json TEXT,
11
+ final_decision_json TEXT NOT NULL,
12
+ llm_used INTEGER NOT NULL,
13
+ cache_hit INTEGER NOT NULL,
14
+ prompt_tokens_approx INTEGER,
15
+ completion_tokens_approx INTEGER,
16
+ rate_limit_status TEXT,
17
+ fallback_reason TEXT
18
+ );
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_prompt_samples_created_at
21
+ ON prompt_samples(created_at);
22
+
23
+ CREATE INDEX IF NOT EXISTS idx_prompt_samples_message_hash
24
+ ON prompt_samples(message_hash);
@@ -0,0 +1,19 @@
1
+ name = "product-spec-pm-intent-gate"
2
+ main = "pm-intent-gate.mjs"
3
+ compatibility_date = "2026-06-23"
4
+
5
+ kv_namespaces = [
6
+ { binding = "PROMPT_CACHE", id = "replace-with-kv-namespace-id" }
7
+ ]
8
+
9
+ d1_databases = [
10
+ { binding = "PROMPT_SAMPLES", database_name = "product-spec-prompt-samples", database_id = "replace-with-d1-database-id" }
11
+ ]
12
+
13
+ [vars]
14
+ DEEPSEEK_MODEL = "deepseek-chat"
15
+
16
+ # Secrets to set with wrangler:
17
+ # wrangler secret put GATE_TOKEN
18
+ # wrangler secret put DEEPSEEK_API_KEY
19
+ # wrangler secret put RATE_LIMIT_SALT