product-spec-mcp 0.3.26 → 0.3.28
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.cjs +1 -1
- package/docs/online-pm-gate.md +25 -1
- package/package.json +1 -1
- package/workers/pm-intent-gate.mjs +131 -23
- package/workers/wrangler.toml.example +12 -7
package/dist/index.cjs
CHANGED
|
@@ -29682,7 +29682,7 @@ function registerProductSpecAssist(server) {
|
|
|
29682
29682
|
function createServer() {
|
|
29683
29683
|
const server = new McpServer({
|
|
29684
29684
|
name: "product-spec-mcp",
|
|
29685
|
-
version: "0.3.
|
|
29685
|
+
version: "0.3.28"
|
|
29686
29686
|
});
|
|
29687
29687
|
registerSpecInterrogate(server);
|
|
29688
29688
|
registerSpecCompile(server);
|
package/docs/online-pm-gate.md
CHANGED
|
@@ -41,11 +41,35 @@ wrangler kv namespace create PROMPT_CACHE
|
|
|
41
41
|
wrangler d1 create product-spec-prompt-samples
|
|
42
42
|
wrangler d1 execute product-spec-prompt-samples --file schema.sql
|
|
43
43
|
wrangler secret put GATE_TOKEN
|
|
44
|
-
wrangler secret put
|
|
44
|
+
wrangler secret put MIMO_API_KEY
|
|
45
45
|
wrangler secret put RATE_LIMIT_SALT
|
|
46
46
|
wrangler deploy
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
Default LLM provider:
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
[vars]
|
|
53
|
+
LLM_PROVIDER = "mimo"
|
|
54
|
+
LLM_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
|
|
55
|
+
LLM_MODEL = "mimo-v2.5"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
To switch later to DeepSeek, change the Worker vars to:
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
[vars]
|
|
62
|
+
LLM_PROVIDER = "deepseek"
|
|
63
|
+
LLM_BASE_URL = "https://api.deepseek.com"
|
|
64
|
+
LLM_MODEL = "deepseek-chat"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then set:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
wrangler secret put DEEPSEEK_API_KEY
|
|
71
|
+
```
|
|
72
|
+
|
|
49
73
|
Runtime behavior:
|
|
50
74
|
|
|
51
75
|
- Prompt cache key: `cache:{model}:{promptHash}:pm-gate-v1`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "product-spec-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.28",
|
|
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",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
const GATE_SCHEMA_VERSION = "pm-gate-v1";
|
|
2
|
-
const
|
|
2
|
+
const DEFAULT_PROVIDER = "mimo";
|
|
3
|
+
const DEFAULT_MIMO_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1";
|
|
4
|
+
const DEFAULT_MIMO_MODEL = "mimo-v2.5";
|
|
5
|
+
const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
6
|
+
const DEFAULT_DEEPSEEK_MODEL = "deepseek-chat";
|
|
3
7
|
const DAILY_LIMIT = 3;
|
|
4
8
|
|
|
5
9
|
export default {
|
|
@@ -25,8 +29,8 @@ export default {
|
|
|
25
29
|
const telemetryMode = normalizeTelemetry(request.headers.get("x-product-spec-telemetry") || "off");
|
|
26
30
|
const message = String(body.message || "").slice(0, 500);
|
|
27
31
|
const messageHash = body.messageHash || await sha256(normalizeText(message));
|
|
28
|
-
const
|
|
29
|
-
const cacheKey = `cache:${model}:${messageHash}:${GATE_SCHEMA_VERSION}`;
|
|
32
|
+
const llm = resolveLlmConfig(env);
|
|
33
|
+
const cacheKey = `cache:${llm.provider}:${llm.model}:${messageHash}:${GATE_SCHEMA_VERSION}`;
|
|
30
34
|
const cached = await env.PROMPT_CACHE?.get(cacheKey, "json");
|
|
31
35
|
const ipKey = await rateLimitKey(request, env);
|
|
32
36
|
const resetAt = nextShanghaiMidnightIso();
|
|
@@ -41,8 +45,8 @@ export default {
|
|
|
41
45
|
decision: cached.decision,
|
|
42
46
|
llmGate: {
|
|
43
47
|
used: false,
|
|
44
|
-
provider:
|
|
45
|
-
model,
|
|
48
|
+
provider: llm.provider,
|
|
49
|
+
model: llm.model,
|
|
46
50
|
promptTokensApprox: cached.promptTokensApprox || 0,
|
|
47
51
|
completionTokensApprox: cached.completionTokensApprox || 0,
|
|
48
52
|
cacheHit: true,
|
|
@@ -66,7 +70,7 @@ export default {
|
|
|
66
70
|
});
|
|
67
71
|
return json({
|
|
68
72
|
decision: fallbackDecision(body.ruleDecision),
|
|
69
|
-
llmGate: { used: false, provider:
|
|
73
|
+
llmGate: { used: false, provider: llm.provider, model: llm.model, cacheHit: false },
|
|
70
74
|
rateLimit: { limit: DAILY_LIMIT, remaining: 0, resetAt },
|
|
71
75
|
privacy: privacyResult(telemetryMode),
|
|
72
76
|
}, 429);
|
|
@@ -79,7 +83,7 @@ export default {
|
|
|
79
83
|
let completionTokensApprox = 0;
|
|
80
84
|
let fallbackReason = "";
|
|
81
85
|
try {
|
|
82
|
-
const llmText = await
|
|
86
|
+
const llmText = await callOpenAiCompatible(llm, prompt);
|
|
83
87
|
completionTokensApprox = approxTokens(llmText);
|
|
84
88
|
llmDecision = sanitizeDecision(extractJson(llmText));
|
|
85
89
|
if (!llmDecision) fallbackReason = "invalid_llm_schema";
|
|
@@ -109,8 +113,8 @@ export default {
|
|
|
109
113
|
decision: finalDecision,
|
|
110
114
|
llmGate: {
|
|
111
115
|
used: Boolean(llmDecision),
|
|
112
|
-
provider:
|
|
113
|
-
model,
|
|
116
|
+
provider: llm.provider,
|
|
117
|
+
model: llm.model,
|
|
114
118
|
promptTokensApprox,
|
|
115
119
|
completionTokensApprox,
|
|
116
120
|
cacheHit: false,
|
|
@@ -133,7 +137,21 @@ function isAuthorized(request, env) {
|
|
|
133
137
|
|
|
134
138
|
function buildGatePrompt(message, rule, choices) {
|
|
135
139
|
return JSON.stringify({
|
|
136
|
-
task: "Choose the best PM gate only. Return strict JSON
|
|
140
|
+
task: "Choose the best PM gate only. Return strict JSON only.",
|
|
141
|
+
example: {
|
|
142
|
+
bestGate: "data_visualization_site",
|
|
143
|
+
usageScope: "self",
|
|
144
|
+
maintenanceMode: "agent_assisted",
|
|
145
|
+
accessTopology: "single_device",
|
|
146
|
+
confidence: "medium",
|
|
147
|
+
strongSignals: ["xlsx"],
|
|
148
|
+
weakSignals: ["website"],
|
|
149
|
+
coreObjects: ["xlsx file"],
|
|
150
|
+
states: [],
|
|
151
|
+
actions: ["parse xlsx", "render chart"],
|
|
152
|
+
mustNotUse: ["admin_backend_by_default"],
|
|
153
|
+
boundaryQuestionIds: ["data_update_mode"],
|
|
154
|
+
},
|
|
137
155
|
output: {
|
|
138
156
|
bestGate: "one needType enum",
|
|
139
157
|
usageScope: "one usageScope enum",
|
|
@@ -159,46 +177,136 @@ function buildGatePrompt(message, rule, choices) {
|
|
|
159
177
|
});
|
|
160
178
|
}
|
|
161
179
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
180
|
+
function resolveLlmConfig(env) {
|
|
181
|
+
const provider = String(env.LLM_PROVIDER || DEFAULT_PROVIDER).toLowerCase();
|
|
182
|
+
if (provider === "deepseek") {
|
|
183
|
+
return {
|
|
184
|
+
provider,
|
|
185
|
+
baseUrl: env.LLM_BASE_URL || env.DEEPSEEK_BASE_URL || DEFAULT_DEEPSEEK_BASE_URL,
|
|
186
|
+
model: env.LLM_MODEL || env.DEEPSEEK_MODEL || DEFAULT_DEEPSEEK_MODEL,
|
|
187
|
+
apiKey: env.LLM_API_KEY || env.DEEPSEEK_API_KEY,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
provider: "mimo",
|
|
192
|
+
baseUrl: env.LLM_BASE_URL || env.MIMO_BASE_URL || DEFAULT_MIMO_BASE_URL,
|
|
193
|
+
model: env.LLM_MODEL || env.MIMO_MODEL || DEFAULT_MIMO_MODEL,
|
|
194
|
+
apiKey: env.LLM_API_KEY || env.MIMO_API_KEY,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function callOpenAiCompatible(llm, prompt) {
|
|
199
|
+
if (!llm.apiKey) throw new Error(`missing_${llm.provider}_api_key`);
|
|
200
|
+
const response = await fetch(`${normalizeBaseUrl(llm.baseUrl)}/chat/completions`, {
|
|
165
201
|
method: "POST",
|
|
166
202
|
headers: {
|
|
167
203
|
"content-type": "application/json",
|
|
168
|
-
authorization: `Bearer ${
|
|
204
|
+
authorization: `Bearer ${llm.apiKey}`,
|
|
169
205
|
},
|
|
170
206
|
body: JSON.stringify({
|
|
171
|
-
model,
|
|
207
|
+
model: llm.model,
|
|
172
208
|
temperature: 0.1,
|
|
173
209
|
max_tokens: 600,
|
|
210
|
+
response_format: { type: "json_object" },
|
|
174
211
|
messages: [
|
|
175
|
-
{
|
|
212
|
+
{
|
|
213
|
+
role: "system",
|
|
214
|
+
content: [
|
|
215
|
+
"You are a terse product intent classifier.",
|
|
216
|
+
"Return exactly one valid JSON object.",
|
|
217
|
+
"Do not use markdown, code fences, comments, or prose.",
|
|
218
|
+
"Use only enum values supplied by the user.",
|
|
219
|
+
].join(" "),
|
|
220
|
+
},
|
|
176
221
|
{ role: "user", content: prompt },
|
|
177
222
|
],
|
|
178
223
|
}),
|
|
179
224
|
});
|
|
180
|
-
if (!response.ok) throw new Error(
|
|
225
|
+
if (!response.ok) throw new Error(`${llm.provider}_http_${response.status}`);
|
|
181
226
|
const data = await response.json();
|
|
182
|
-
|
|
183
|
-
|
|
227
|
+
if (data?.error) throw new Error(`${llm.provider}_error_${data.error.code || data.error.type || "unknown"}`);
|
|
228
|
+
const content = extractOpenAiCompatibleContent(data);
|
|
229
|
+
if (typeof content !== "string" || !content.trim()) throw new Error(`${llm.provider}_empty_content`);
|
|
184
230
|
return content;
|
|
185
231
|
}
|
|
186
232
|
|
|
233
|
+
function normalizeBaseUrl(baseUrl) {
|
|
234
|
+
return String(baseUrl || "").replace(/\/+$/, "");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function extractOpenAiCompatibleContent(data) {
|
|
238
|
+
const choice = data?.choices?.[0];
|
|
239
|
+
const message = choice?.message || {};
|
|
240
|
+
const content = message.content;
|
|
241
|
+
if (typeof content === "string" && content.trim()) return content;
|
|
242
|
+
if (Array.isArray(content)) {
|
|
243
|
+
const text = content
|
|
244
|
+
.map((part) => {
|
|
245
|
+
if (typeof part === "string") return part;
|
|
246
|
+
if (typeof part?.text === "string") return part.text;
|
|
247
|
+
if (typeof part?.content === "string") return part.content;
|
|
248
|
+
return "";
|
|
249
|
+
})
|
|
250
|
+
.join("");
|
|
251
|
+
if (text.trim()) return text;
|
|
252
|
+
}
|
|
253
|
+
if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) return message.reasoning_content;
|
|
254
|
+
if (typeof choice?.text === "string" && choice.text.trim()) return choice.text;
|
|
255
|
+
if (typeof data?.output_text === "string" && data.output_text.trim()) return data.output_text;
|
|
256
|
+
return "";
|
|
257
|
+
}
|
|
258
|
+
|
|
187
259
|
function extractJson(text) {
|
|
188
260
|
try {
|
|
189
261
|
return JSON.parse(text);
|
|
190
262
|
} catch {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
263
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
264
|
+
if (fenced) {
|
|
265
|
+
try {
|
|
266
|
+
return JSON.parse(fenced[1]);
|
|
267
|
+
} catch {
|
|
268
|
+
// Continue to balanced object extraction below.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const candidate = extractFirstBalancedObject(text);
|
|
272
|
+
if (!candidate) return null;
|
|
194
273
|
try {
|
|
195
|
-
return JSON.parse(
|
|
274
|
+
return JSON.parse(candidate);
|
|
196
275
|
} catch {
|
|
197
276
|
return null;
|
|
198
277
|
}
|
|
199
278
|
}
|
|
200
279
|
}
|
|
201
280
|
|
|
281
|
+
function extractFirstBalancedObject(text) {
|
|
282
|
+
const start = text.indexOf("{");
|
|
283
|
+
if (start < 0) return "";
|
|
284
|
+
let depth = 0;
|
|
285
|
+
let inString = false;
|
|
286
|
+
let escaped = false;
|
|
287
|
+
for (let i = start; i < text.length; i += 1) {
|
|
288
|
+
const char = text[i];
|
|
289
|
+
if (inString) {
|
|
290
|
+
if (escaped) {
|
|
291
|
+
escaped = false;
|
|
292
|
+
} else if (char === "\\") {
|
|
293
|
+
escaped = true;
|
|
294
|
+
} else if (char === "\"") {
|
|
295
|
+
inString = false;
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (char === "\"") {
|
|
300
|
+
inString = true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (char === "{") depth += 1;
|
|
304
|
+
if (char === "}") depth -= 1;
|
|
305
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
306
|
+
}
|
|
307
|
+
return "";
|
|
308
|
+
}
|
|
309
|
+
|
|
202
310
|
function sanitizeDecision(raw) {
|
|
203
311
|
if (!raw || typeof raw !== "object") return null;
|
|
204
312
|
const bestGate = raw.bestGate || raw.needType;
|
|
@@ -2,18 +2,23 @@ name = "product-spec-pm-intent-gate"
|
|
|
2
2
|
main = "pm-intent-gate.mjs"
|
|
3
3
|
compatibility_date = "2026-06-23"
|
|
4
4
|
|
|
5
|
-
kv_namespaces
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
[[kv_namespaces]]
|
|
6
|
+
binding = "PROMPT_CACHE"
|
|
7
|
+
id = "replace-with-kv-namespace-id"
|
|
8
8
|
|
|
9
|
-
d1_databases
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
[[d1_databases]]
|
|
10
|
+
binding = "PROMPT_SAMPLES"
|
|
11
|
+
database_name = "product-spec-prompt-samples"
|
|
12
|
+
database_id = "replace-with-d1-database-id"
|
|
12
13
|
|
|
13
14
|
[vars]
|
|
14
|
-
|
|
15
|
+
LLM_PROVIDER = "mimo"
|
|
16
|
+
LLM_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
|
|
17
|
+
LLM_MODEL = "mimo-v2.5"
|
|
15
18
|
|
|
16
19
|
# Secrets to set with wrangler:
|
|
17
20
|
# wrangler secret put GATE_TOKEN
|
|
21
|
+
# wrangler secret put MIMO_API_KEY
|
|
22
|
+
# Optional fallback/switch later:
|
|
18
23
|
# wrangler secret put DEEPSEEK_API_KEY
|
|
19
24
|
# wrangler secret put RATE_LIMIT_SALT
|