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 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.26"
29685
+ version: "0.3.28"
29686
29686
  });
29687
29687
  registerSpecInterrogate(server);
29688
29688
  registerSpecCompile(server);
@@ -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 DEEPSEEK_API_KEY
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.26",
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 DEFAULT_MODEL = "deepseek-chat";
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 model = env.DEEPSEEK_MODEL || DEFAULT_MODEL;
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: "deepseek",
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: "deepseek", model, cacheHit: false },
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 callDeepSeek(env, model, prompt);
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: "deepseek",
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, no markdown.",
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
- 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", {
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 ${env.DEEPSEEK_API_KEY}`,
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
- { role: "system", content: "You are a terse product intent classifier. Output JSON only." },
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(`deepseek_http_${response.status}`);
225
+ if (!response.ok) throw new Error(`${llm.provider}_http_${response.status}`);
181
226
  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");
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 start = text.indexOf("{");
192
- const end = text.lastIndexOf("}");
193
- if (start < 0 || end <= start) return null;
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(text.slice(start, end + 1));
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
- { binding = "PROMPT_CACHE", id = "replace-with-kv-namespace-id" }
7
- ]
5
+ [[kv_namespaces]]
6
+ binding = "PROMPT_CACHE"
7
+ id = "replace-with-kv-namespace-id"
8
8
 
9
- d1_databases = [
10
- { binding = "PROMPT_SAMPLES", database_name = "product-spec-prompt-samples", database_id = "replace-with-d1-database-id" }
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
- DEEPSEEK_MODEL = "deepseek-chat"
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