product-spec-mcp 0.4.0 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 - Connect page user context
4
+
5
+ - Added AI tool and multi-select use-case fields to the `/connect` page.
6
+ - Stored generated token metadata as `client` and `use_case` for later product direction and billing analysis.
7
+ - Added client-specific configuration hints/snippets to downloaded connect files for WorkBuddy, Claude Desktop, Claude Code, Codex, OpenCode, and generic Agents.
8
+
3
9
  ## 0.4.0 - Self-serve Online Gate connection
4
10
 
5
11
  - Added `product_spec_connect` so Agents can guide users through connecting the Online PM Gate without manual token copying.
package/README.md CHANGED
@@ -208,6 +208,8 @@ Client-specific integration notes are intentionally kept out of the main user fl
208
208
  "client": "workbuddy",
209
209
  "connect_file": {
210
210
  "type": "product-spec-mcp-connect",
211
+ "client": "workbuddy",
212
+ "useCases": ["personal_app_site", "client_requirements"],
211
213
  "instructions": {
212
214
  "env": {
213
215
  "PRODUCT_SPEC_REMOTE_GATE_URL": "https://productmcp.opc-mind.top/v1/pm-intent",
package/dist/index.cjs CHANGED
@@ -30154,7 +30154,7 @@ function formatConnectGuide(result) {
30154
30154
  function createServer() {
30155
30155
  const server = new McpServer({
30156
30156
  name: "product-spec-mcp",
30157
- version: "0.4.0"
30157
+ version: "0.4.1"
30158
30158
  });
30159
30159
  registerSpecInterrogate(server);
30160
30160
  registerSpecCompile(server);
@@ -31,7 +31,12 @@ https://productmcp.opc-mind.top/connect
31
31
  POST /v1/connect-token
32
32
  ```
33
33
 
34
- Worker 会创建一个 `psm_` 开头的专属 token,并返回连接文件。
34
+ 页面会让用户选择两个轻量字段:
35
+
36
+ - 正在用哪个 AI 工具:WorkBuddy / Claude Desktop / Claude Code / Codex / OpenCode / 其他(请填写)
37
+ - 准备用它做什么:给自己做小应用 / 网站、帮客户梳理需求、公司 / 团队内部项目、学习或测试 MCP、其他(请填写),支持多选
38
+
39
+ Worker 会创建一个 `psm_` 开头的专属 token,保存这两个字段,并返回连接文件。
35
40
 
36
41
  ## Connect File
37
42
 
@@ -47,6 +52,10 @@ product-spec-mcp-connect.json
47
52
  {
48
53
  "type": "product-spec-mcp-connect",
49
54
  "version": 1,
55
+ "client": "workbuddy",
56
+ "clientKey": "workbuddy",
57
+ "useCase": "personal_app_site,client_requirements",
58
+ "useCases": ["personal_app_site", "client_requirements"],
50
59
  "remoteGate": {
51
60
  "url": "https://productmcp.opc-mind.top/v1/pm-intent",
52
61
  "token": "psm_xxx",
@@ -62,6 +71,13 @@ product-spec-mcp-connect.json
62
71
  "PRODUCT_SPEC_REMOTE_GATE_MODE": "auto",
63
72
  "PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS": "10000",
64
73
  "PRODUCT_SPEC_TELEMETRY": "off"
74
+ },
75
+ "clientHint": {
76
+ "target": "WorkBuddy MCP wrapper",
77
+ "action": "把 instructions.env 写入 WorkBuddy 中 product-spec MCP server 的环境变量配置,然后重启 MCP。"
78
+ },
79
+ "configSnippet": {
80
+ "env": {}
65
81
  }
66
82
  }
67
83
  }
@@ -122,6 +138,15 @@ usage_events
122
138
 
123
139
  `api_tokens` 存 token hash,不存明文 token。`usage_events` 记录按 token 的 LLM 使用情况,后续可以接入计费、月额度、封禁和用户面板。
124
140
 
141
+ `api_tokens` 还会保存:
142
+
143
+ ```sql
144
+ client TEXT,
145
+ use_case TEXT
146
+ ```
147
+
148
+ 这两个字段用于判断真实用户分布和后续生成更准确的 Agent 配置提示。
149
+
125
150
  ## Quotas
126
151
 
127
152
  默认每日额度仍由 Worker 变量控制:
@@ -93,6 +93,8 @@ POST /v1/connect-token
93
93
 
94
94
  用户打开 `/connect` 后点击下载 `product-spec-mcp-connect.json`。文件里包含当前 Agent 应写入 MCP 配置的环境变量:
95
95
 
96
+ 页面会额外收集两个轻量字段:当前 AI 工具和主要用途。AI 工具选项是 WorkBuddy / Claude Desktop / Claude Code / Codex / OpenCode / 其他;主要用途支持多选,包括给自己做小应用 / 网站、帮客户梳理需求、公司 / 团队内部项目、学习或测试 MCP、其他。它们会写入 D1 的 token metadata,用于后续产品方向分析和生成更准确的配置片段。
97
+
96
98
  ```json
97
99
  {
98
100
  "PRODUCT_SPEC_REMOTE_GATE_URL": "https://productmcp.opc-mind.top/v1/pm-intent",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-spec-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
@@ -14,6 +14,7 @@
14
14
  "docs/connect-flow.md",
15
15
  "docs/online-pm-gate.md",
16
16
  "workers/pm-intent-gate.mjs",
17
+ "workers/migrations/*.sql",
17
18
  "workers/schema.sql",
18
19
  "workers/wrangler.toml.example"
19
20
  ],
@@ -0,0 +1,2 @@
1
+ ALTER TABLE api_tokens ADD COLUMN client TEXT;
2
+ ALTER TABLE api_tokens ADD COLUMN use_case TEXT;
@@ -281,35 +281,45 @@ async function createConnectToken(request, env, origin) {
281
281
  const now = new Date().toISOString();
282
282
  const dailyLimit = resolveConnectTokenDailyLimit(env);
283
283
  const monthlyLimit = positiveIntegerOrNull(env.CONNECT_TOKEN_MONTHLY_LIMIT);
284
- const client = sanitizeShortText(body.client || "unknown", 40);
284
+ const clientKey = normalizeSubmittedClient(body.client);
285
+ const clientOther = sanitizeOptionalText(body.clientOther || body.client_other, 60);
286
+ const client = clientKey === "other" && clientOther ? `other:${clientOther}` : clientKey;
287
+ const useCases = normalizeSubmittedUseCases(body);
288
+ const useCase = useCases.length > 0 ? useCases.join(",") : "unknown";
285
289
  const label = sanitizeShortText(body.label || `${client} connect token`, 80);
286
290
 
287
291
  await env.PROMPT_SAMPLES.prepare(
288
292
  `INSERT INTO api_tokens (
289
- id, token_hash, token_prefix, label, daily_limit, monthly_limit, enabled, created_at, last_used_at
290
- ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, NULL)`
293
+ id, token_hash, token_prefix, label, client, use_case, daily_limit, monthly_limit, enabled, created_at, last_used_at
294
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, NULL)`
291
295
  ).bind(
292
296
  id,
293
297
  tokenHash,
294
298
  token.slice(0, 12),
295
299
  label,
300
+ client,
301
+ useCase,
296
302
  dailyLimit,
297
303
  monthlyLimit,
298
304
  now
299
305
  ).run();
300
306
 
301
307
  const remoteGateUrl = resolveRemoteGateUrl(env, origin);
302
- const connectFile = buildConnectFile(remoteGateUrl, token);
308
+ const connectFile = buildConnectFile(remoteGateUrl, token, clientKey, client, useCases);
303
309
  return json({
304
310
  ok: true,
305
311
  tokenPrefix: token.slice(0, 12),
306
312
  dailyLimit,
307
313
  monthlyLimit,
314
+ client,
315
+ clientKey,
316
+ useCase,
317
+ useCases,
308
318
  connectFile,
309
319
  });
310
320
  }
311
321
 
312
- function buildConnectFile(remoteGateUrl, token) {
322
+ function buildConnectFile(remoteGateUrl, token, clientKey = "other", client = "other", useCases = []) {
313
323
  const env = {
314
324
  PRODUCT_SPEC_REMOTE_GATE_URL: remoteGateUrl,
315
325
  PRODUCT_SPEC_REMOTE_GATE_TOKEN: token,
@@ -320,6 +330,10 @@ function buildConnectFile(remoteGateUrl, token) {
320
330
  return {
321
331
  type: "product-spec-mcp-connect",
322
332
  version: 1,
333
+ client,
334
+ clientKey,
335
+ useCase: useCases.join(",") || "unknown",
336
+ useCases,
323
337
  remoteGate: {
324
338
  url: remoteGateUrl,
325
339
  token,
@@ -330,10 +344,115 @@ function buildConnectFile(remoteGateUrl, token) {
330
344
  instructions: {
331
345
  summary: "请把 remoteGate 配置写入当前 Agent 的 product-spec-mcp 环境变量。",
332
346
  env,
347
+ clientHint: clientConnectHint(clientKey),
348
+ configSnippet: clientConfigSnippet(clientKey, env),
333
349
  },
334
350
  };
335
351
  }
336
352
 
353
+ function normalizeSubmittedClient(value) {
354
+ const normalized = normalizeClient(value);
355
+ return ["workbuddy", "claude_desktop", "claude_code", "codex", "opencode", "other"].includes(normalized)
356
+ ? normalized
357
+ : "other";
358
+ }
359
+
360
+ function normalizeSubmittedUseCases(body) {
361
+ const rawValues = Array.isArray(body.useCases)
362
+ ? body.useCases
363
+ : String(body.useCase || body.use_case || "")
364
+ .split(",")
365
+ .map((item) => item.trim())
366
+ .filter(Boolean);
367
+ const allowed = new Set(["personal_app_site", "client_requirements", "internal_team", "learning_testing", "other"]);
368
+ const normalized = [];
369
+ for (const value of rawValues) {
370
+ const key = String(value || "").trim();
371
+ if (allowed.has(key) && !normalized.includes(key)) normalized.push(key);
372
+ }
373
+ const otherText = sanitizeOptionalText(body.useCaseOther || body.use_case_other, 80);
374
+ if (normalized.includes("other") && otherText) {
375
+ normalized[normalized.indexOf("other")] = `other:${otherText}`;
376
+ }
377
+ return normalized;
378
+ }
379
+
380
+ function clientConnectHint(client) {
381
+ const normalized = normalizeClient(client);
382
+ const hints = {
383
+ workbuddy: {
384
+ target: "WorkBuddy MCP wrapper",
385
+ action: "把 instructions.env 写入 WorkBuddy 中 product-spec MCP server 的环境变量配置,然后重启 MCP。",
386
+ },
387
+ cursor: {
388
+ target: "Cursor MCP configuration",
389
+ action: "把 configSnippet 合并到 Cursor 的 MCP 配置;如果已有 product-spec server,只补 env。",
390
+ },
391
+ claude_desktop: {
392
+ target: "Claude Desktop MCP configuration",
393
+ action: "把 configSnippet 合并到 claude_desktop_config.json;如果已有 product-spec server,只补 env。",
394
+ },
395
+ claude_code: {
396
+ target: "Claude Code MCP configuration",
397
+ action: "把 instructions.env 写入 Claude Code 中 product-spec MCP server 的环境变量配置,然后重启 MCP。",
398
+ },
399
+ codex: {
400
+ target: "Codex MCP configuration",
401
+ action: "把 instructions.env 写入当前 Codex 可用的 product-spec MCP server 配置,然后重启 MCP。",
402
+ },
403
+ opencode: {
404
+ target: "opencode MCP configuration",
405
+ action: "把 configSnippet 合并到 ~/.config/opencode/opencode.json;如果已有 product-spec server,只补 env。",
406
+ },
407
+ other: {
408
+ target: "Generic MCP server configuration",
409
+ action: "把 instructions.env 写入 product-spec MCP server 的环境变量配置,然后重启 MCP。",
410
+ },
411
+ };
412
+ return hints[normalized] || hints.other;
413
+ }
414
+
415
+ function clientConfigSnippet(client, env) {
416
+ const normalized = normalizeClient(client);
417
+ if (normalized === "opencode") {
418
+ return {
419
+ mcp: {
420
+ "product-spec": {
421
+ type: "local",
422
+ command: ["npx", "-y", "product-spec-mcp@latest"],
423
+ enabled: true,
424
+ timeout: 30000,
425
+ env,
426
+ },
427
+ },
428
+ };
429
+ }
430
+ if (normalized === "cursor" || normalized === "claude_desktop") {
431
+ return {
432
+ mcpServers: {
433
+ "product-spec": {
434
+ command: "npx",
435
+ args: ["-y", "product-spec-mcp@latest"],
436
+ env,
437
+ },
438
+ },
439
+ };
440
+ }
441
+ return { env };
442
+ }
443
+
444
+ function normalizeClient(client) {
445
+ const value = String(client || "").toLowerCase().replace(/[\s-]+/g, "_");
446
+ if (value.includes("workbuddy")) return "workbuddy";
447
+ if (value.includes("cursor")) return "cursor";
448
+ if (value.includes("claude_code")) return "claude_code";
449
+ if (value.includes("claude") && value.includes("code")) return "claude_code";
450
+ if (value.includes("claude")) return "claude_desktop";
451
+ if (value.includes("codex")) return "codex";
452
+ if (value.includes("opencode")) return "opencode";
453
+ return ["workbuddy", "cursor", "claude_desktop", "claude_code", "codex", "opencode", "other"].includes(value) ? value : "other";
454
+ }
455
+
337
456
  function connectPageHtml(origin) {
338
457
  const apiUrl = `${origin}/v1/connect-token`;
339
458
  return `<!doctype html>
@@ -352,6 +471,15 @@ function connectPageHtml(origin) {
352
471
  .steps { display: grid; gap: 12px; margin: 26px 0; padding: 0; list-style: none; }
353
472
  .steps li { display: flex; gap: 12px; align-items: flex-start; color: #243246; }
354
473
  .num { flex: 0 0 28px; height: 28px; border-radius: 999px; background: #0f766e; color: white; display: grid; place-items: center; font-weight: 700; font-size: 14px; }
474
+ .form { display: grid; gap: 18px; margin: 24px 0; }
475
+ label { display: grid; gap: 8px; font-size: 14px; font-weight: 700; color: #243246; }
476
+ select, input[type="text"] { width: 100%; box-sizing: border-box; border: 1px solid #d8e0eb; border-radius: 8px; background: white; color: #172033; font: inherit; padding: 12px 14px; }
477
+ select:focus, input[type="text"]:focus { outline: 2px solid rgba(15, 118, 110, .24); border-color: #0f766e; }
478
+ .hint { font-size: 13px; font-weight: 500; color: #7a8698; }
479
+ .hidden { display: none; }
480
+ .checks { display: grid; gap: 10px; }
481
+ .check { display: flex; gap: 10px; align-items: center; font-size: 15px; font-weight: 600; color: #243246; }
482
+ .check input { width: 18px; height: 18px; accent-color: #0f766e; }
355
483
  button { appearance: none; border: 0; border-radius: 8px; background: #0f766e; color: white; font-size: 16px; font-weight: 700; padding: 14px 18px; cursor: pointer; }
356
484
  button:disabled { opacity: .65; cursor: wait; }
357
485
  .status { margin-top: 16px; font-size: 14px; color: #526071; min-height: 22px; }
@@ -368,6 +496,33 @@ function connectPageHtml(origin) {
368
496
  <li><span class="num">2</span><span>把下载的 <strong>product-spec-mcp-connect.json</strong> 拖回或上传到 Agent 对话。</span></li>
369
497
  <li><span class="num">3</span><span>让 Agent 按文件里的说明完成配置并重启 MCP。</span></li>
370
498
  </ol>
499
+ <div class="form">
500
+ <label>
501
+ 你正在用哪个 AI 工具?
502
+ <span class="hint">用于生成更准确的配置说明</span>
503
+ <select id="client">
504
+ <option value="workbuddy">WorkBuddy</option>
505
+ <option value="claude_desktop">Claude Desktop</option>
506
+ <option value="claude_code">Claude Code</option>
507
+ <option value="codex">Codex</option>
508
+ <option value="opencode">OpenCode</option>
509
+ <option value="other">其他</option>
510
+ </select>
511
+ <input id="clientOther" class="hidden" type="text" maxlength="60" placeholder="请填写 AI 工具名称">
512
+ </label>
513
+ <label>
514
+ 你准备用它做什么?
515
+ <span class="hint">可多选,只用于改进 product-spec,不影响连接</span>
516
+ <span class="checks" id="useCases">
517
+ <label class="check"><input type="checkbox" value="personal_app_site" checked>给自己做小应用 / 网站</label>
518
+ <label class="check"><input type="checkbox" value="client_requirements">帮客户梳理需求</label>
519
+ <label class="check"><input type="checkbox" value="internal_team">公司 / 团队内部项目</label>
520
+ <label class="check"><input type="checkbox" value="learning_testing">学习或测试 MCP</label>
521
+ <label class="check"><input id="useCaseOtherCheck" type="checkbox" value="other">其他</label>
522
+ </span>
523
+ <input id="useCaseOther" class="hidden" type="text" maxlength="80" placeholder="请填写其他用途">
524
+ </label>
525
+ </div>
371
526
  <button id="download">生成并下载连接文件</button>
372
527
  <div class="status" id="status"></div>
373
528
  <p class="fine">连接文件里包含你的专属访问 token,请不要公开分享。默认额度由服务端配置控制。</p>
@@ -376,14 +531,31 @@ function connectPageHtml(origin) {
376
531
  <script>
377
532
  const button = document.getElementById("download");
378
533
  const status = document.getElementById("status");
534
+ const client = document.getElementById("client");
535
+ const clientOther = document.getElementById("clientOther");
536
+ const useCaseOtherCheck = document.getElementById("useCaseOtherCheck");
537
+ const useCaseOther = document.getElementById("useCaseOther");
538
+ client.addEventListener("change", () => {
539
+ clientOther.classList.toggle("hidden", client.value !== "other");
540
+ });
541
+ useCaseOtherCheck.addEventListener("change", () => {
542
+ useCaseOther.classList.toggle("hidden", !useCaseOtherCheck.checked);
543
+ });
379
544
  button.addEventListener("click", async () => {
380
545
  button.disabled = true;
381
546
  status.textContent = "正在生成连接文件...";
382
547
  try {
548
+ const useCases = Array.from(document.querySelectorAll("#useCases input[type='checkbox']:checked"))
549
+ .map((item) => item.value);
383
550
  const response = await fetch(${JSON.stringify(apiUrl)}, {
384
551
  method: "POST",
385
552
  headers: { "content-type": "application/json" },
386
- body: JSON.stringify({ client: "browser-connect-page" })
553
+ body: JSON.stringify({
554
+ client: client.value,
555
+ clientOther: clientOther.value,
556
+ useCases,
557
+ useCaseOther: useCaseOther.value
558
+ })
387
559
  });
388
560
  const payload = await response.json();
389
561
  if (!response.ok || !payload.connectFile) throw new Error(payload.error || "connect_failed");
@@ -426,11 +598,18 @@ function positiveIntegerOrNull(value) {
426
598
 
427
599
  function sanitizeShortText(value, maxLength) {
428
600
  return String(value || "")
429
- .replace(/[^\w\s.@:-]/g, "")
601
+ .replace(/[^\p{L}\p{N}\s.@:_-]/gu, "")
430
602
  .trim()
431
603
  .slice(0, maxLength) || "product-spec-mcp";
432
604
  }
433
605
 
606
+ function sanitizeOptionalText(value, maxLength) {
607
+ return String(value || "")
608
+ .replace(/[^\p{L}\p{N}\s.@:_-]/gu, "")
609
+ .trim()
610
+ .slice(0, maxLength);
611
+ }
612
+
434
613
  async function callOpenAiCompatible(llm, prompt) {
435
614
  if (!llm.apiKey) throw new Error(`missing_${llm.provider}_api_key`);
436
615
  const response = await fetch(`${normalizeBaseUrl(llm.baseUrl)}/chat/completions`, {
@@ -28,6 +28,8 @@ CREATE TABLE IF NOT EXISTS api_tokens (
28
28
  token_hash TEXT UNIQUE NOT NULL,
29
29
  token_prefix TEXT NOT NULL,
30
30
  label TEXT,
31
+ client TEXT,
32
+ use_case TEXT,
31
33
  daily_limit INTEGER NOT NULL,
32
34
  monthly_limit INTEGER,
33
35
  enabled INTEGER NOT NULL,