product-spec-mcp 0.3.27 → 0.3.29

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/README.md CHANGED
@@ -61,7 +61,7 @@ npm run dev
61
61
  ```bash
62
62
  PRODUCT_SPEC_REMOTE_GATE_URL=https://gate.example.com/v1/pm-intent
63
63
  PRODUCT_SPEC_REMOTE_GATE_TOKEN=replace-with-token
64
- PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS=2500
64
+ PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS=10000
65
65
  PRODUCT_SPEC_REMOTE_GATE_MODE=auto
66
66
  PRODUCT_SPEC_TELEMETRY=off
67
67
  ```
package/dist/index.cjs CHANGED
@@ -23811,7 +23811,7 @@ function decideRecommendedDeployment(needType, technicalShape, maintenanceMode,
23811
23811
  if (accessTopology === "lan_only") return "local_lan_server_sqlite";
23812
23812
  if (accessTopology === "internet_ip") return "cheap_vps_sqlite_by_ip";
23813
23813
  if (accessTopology === "public_domain") return "vps_domain_https";
23814
- if (technicalShape === "light_backend_json_sqlite") return "cheap_vps_sqlite_by_ip";
23814
+ if (technicalShape === "light_backend_json_sqlite") return "unknown";
23815
23815
  return "unknown";
23816
23816
  }
23817
23817
  function decideRoute(needType, technicalShape, maintenanceMode) {
@@ -23833,13 +23833,19 @@ function enforceHardRules(decision) {
23833
23833
  needType: "multi_user_collaboration",
23834
23834
  maintenanceMode: "runtime_collaboration",
23835
23835
  technicalShape: "light_backend_json_sqlite",
23836
- recommendedDeployment: decision.accessTopology === "lan_only" ? "local_lan_server_sqlite" : "cheap_vps_sqlite_by_ip",
23836
+ recommendedDeployment: recommendedDeploymentForCollaboration(decision.accessTopology),
23837
23837
  route: "spec_interrogate",
23838
23838
  mustNotUse: Array.from(/* @__PURE__ */ new Set([...decision.mustNotUse, "static_display", "local_storage_only"]))
23839
23839
  };
23840
23840
  }
23841
23841
  return decision;
23842
23842
  }
23843
+ function recommendedDeploymentForCollaboration(accessTopology) {
23844
+ if (accessTopology === "lan_only") return "local_lan_server_sqlite";
23845
+ if (accessTopology === "internet_ip") return "cheap_vps_sqlite_by_ip";
23846
+ if (accessTopology === "public_domain") return "vps_domain_https";
23847
+ return "unknown";
23848
+ }
23843
23849
  function buildMustNotUse(needType, maintenanceMode) {
23844
23850
  const items = [];
23845
23851
  if (needType === "multi_user_collaboration") items.push("static_display", "local_storage_only");
@@ -27702,7 +27708,7 @@ async function callRemotePmIntentGate(message, context, localDecision) {
27702
27708
  const url = process.env.PRODUCT_SPEC_REMOTE_GATE_URL;
27703
27709
  if (!url) return null;
27704
27710
  const controller = new AbortController();
27705
- const timeout = Number(process.env.PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS || 2500);
27711
+ const timeout = Number(process.env.PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS || 1e4);
27706
27712
  const timer = setTimeout(() => controller.abort(), timeout);
27707
27713
  try {
27708
27714
  const response = await fetch(url, {
@@ -27874,6 +27880,7 @@ function enforceRemoteHardRules(remote, local) {
27874
27880
  needType: "multi_user_collaboration",
27875
27881
  maintenanceMode: "runtime_collaboration",
27876
27882
  technicalShape: "light_backend_json_sqlite",
27883
+ recommendedDeployment: recommendedDeploymentForCollaboration2(remote.accessTopology),
27877
27884
  route: "spec_interrogate",
27878
27885
  mustNotUse: mergeStrings(remote.mustNotUse, ["static_display", "local_storage_only"])
27879
27886
  };
@@ -27891,6 +27898,12 @@ function enforceRemoteHardRules(remote, local) {
27891
27898
  }
27892
27899
  return remote;
27893
27900
  }
27901
+ function recommendedDeploymentForCollaboration2(accessTopology) {
27902
+ if (accessTopology === "lan_only") return "local_lan_server_sqlite";
27903
+ if (accessTopology === "internet_ip") return "cheap_vps_sqlite_by_ip";
27904
+ if (accessTopology === "public_domain") return "vps_domain_https";
27905
+ return "unknown";
27906
+ }
27894
27907
  function hasRouteShapeConflict(decision) {
27895
27908
  return decision.route === "architecture_decide" && decision.technicalShape !== "static_page";
27896
27909
  }
@@ -29682,7 +29695,7 @@ function registerProductSpecAssist(server) {
29682
29695
  function createServer() {
29683
29696
  const server = new McpServer({
29684
29697
  name: "product-spec-mcp",
29685
- version: "0.3.27"
29698
+ version: "0.3.29"
29686
29699
  });
29687
29700
  registerSpecInterrogate(server);
29688
29701
  registerSpecCompile(server);
@@ -7,7 +7,7 @@ P0 online gate is an HTTP classifier for low-confidence or conflicting local PM
7
7
  ```bash
8
8
  PRODUCT_SPEC_REMOTE_GATE_URL=https://gate.example.com/v1/pm-intent
9
9
  PRODUCT_SPEC_REMOTE_GATE_TOKEN=replace-with-token
10
- PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS=2500
10
+ PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS=10000
11
11
  PRODUCT_SPEC_REMOTE_GATE_MODE=auto
12
12
  PRODUCT_SPEC_TELEMETRY=off
13
13
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-spec-mcp",
3
- "version": "0.3.27",
3
+ "version": "0.3.29",
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",
@@ -137,7 +137,21 @@ function isAuthorized(request, env) {
137
137
 
138
138
  function buildGatePrompt(message, rule, choices) {
139
139
  return JSON.stringify({
140
- 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
+ },
141
155
  output: {
142
156
  bestGate: "one needType enum",
143
157
  usageScope: "one usageScope enum",
@@ -193,15 +207,25 @@ async function callOpenAiCompatible(llm, prompt) {
193
207
  model: llm.model,
194
208
  temperature: 0.1,
195
209
  max_tokens: 600,
210
+ response_format: { type: "json_object" },
196
211
  messages: [
197
- { 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
+ },
198
221
  { role: "user", content: prompt },
199
222
  ],
200
223
  }),
201
224
  });
202
225
  if (!response.ok) throw new Error(`${llm.provider}_http_${response.status}`);
203
226
  const data = await response.json();
204
- const content = data?.choices?.[0]?.message?.content;
227
+ if (data?.error) throw new Error(`${llm.provider}_error_${data.error.code || data.error.type || "unknown"}`);
228
+ const content = extractOpenAiCompatibleContent(data);
205
229
  if (typeof content !== "string" || !content.trim()) throw new Error(`${llm.provider}_empty_content`);
206
230
  return content;
207
231
  }
@@ -210,21 +234,79 @@ function normalizeBaseUrl(baseUrl) {
210
234
  return String(baseUrl || "").replace(/\/+$/, "");
211
235
  }
212
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
+
213
259
  function extractJson(text) {
214
260
  try {
215
261
  return JSON.parse(text);
216
262
  } catch {
217
- const start = text.indexOf("{");
218
- const end = text.lastIndexOf("}");
219
- 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;
220
273
  try {
221
- return JSON.parse(text.slice(start, end + 1));
274
+ return JSON.parse(candidate);
222
275
  } catch {
223
276
  return null;
224
277
  }
225
278
  }
226
279
  }
227
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
+
228
310
  function sanitizeDecision(raw) {
229
311
  if (!raw || typeof raw !== "object") return null;
230
312
  const bestGate = raw.bestGate || raw.needType;