product-spec-mcp 0.3.34 → 0.4.0

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 ADDED
@@ -0,0 +1,77 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 - Self-serve Online Gate connection
4
+
5
+ - Added `product_spec_connect` so Agents can guide users through connecting the Online PM Gate without manual token copying.
6
+ - Added a Worker `/connect` page and `/v1/connect-token` endpoint that generate `product-spec-mcp-connect.json` files with `psm_` scoped tokens.
7
+ - Added D1 token and usage tables for per-token daily/monthly quota tracking and future paid access.
8
+ - Kept legacy `GATE_TOKEN` compatibility while allowing generated `psm_` tokens to call `/v1/pm-intent`.
9
+ - Added connect-flow docs, README guidance, Worker tests, and black-box MCP regression coverage for the new tool.
10
+
11
+ ## 0.3.28 - Mimo JSON response hardening
12
+
13
+ - Added `response_format: { type: "json_object" }` to Online Gate LLM calls.
14
+ - Strengthened the remote gate prompt with a compact JSON example and stricter JSON-only instructions.
15
+ - Made OpenAI-compatible response parsing tolerate content arrays, reasoning content, plain text choices, and fenced JSON.
16
+ - Verified the deployed Worker can call `mimo-v2.5` and return a valid `data_visualization_site` decision.
17
+
18
+ ## 0.3.27 - Mimo online gate default
19
+
20
+ - Switched the Cloudflare Online Gate template default provider to Mimo's OpenAI-compatible endpoint.
21
+ - Defaulted the remote gate model to `mimo-v2.5` with `LLM_PROVIDER`, `LLM_BASE_URL`, and `LLM_MODEL` Worker vars.
22
+ - Kept DeepSeek as a later switchable provider through Worker vars and `DEEPSEEK_API_KEY`.
23
+
24
+ ## 0.3.26 - PM intent gate
25
+
26
+ - Added a PM-style intent gate that classifies usage scope, maintenance mode, access topology, technical shape, and deployment direction before domain templates.
27
+ - Added gate-specific handling for multi-user collaboration, content marketing sites, and xlsx/csv data visualization sites, including safer defaults and boundary questions.
28
+ - Added an optional remote Online Gate client protocol with local-rule fallback, schema validation, prompt truncation, telemetry mode, and hard local corrections.
29
+ - Added a Cloudflare Workers P0 Online Gate template with KV prompt cache, IP daily limit, D1 redacted sample storage, and an OpenAI-compatible JSON classification provider.
30
+ - Carried `pmIntentDecision` through assist, compile, architecture, and acceptance structured outputs.
31
+ - Added regression coverage for household tools, roommate task collaboration, gym GEO content sites, xlsx chart sites, and negative local/static/backend routing cases.
32
+
33
+ ## 0.3.25 - Local MVP spec quality
34
+
35
+ - Fed local tool signals into generic local-first spec generation so MVP drafts include concrete fields, data examples, and acceptance criteria.
36
+ - Defaulted recognizable household/local record tools to Draft Ready with localStorage scope instead of showing contradictory Not Ready wording.
37
+ - Kept the change horizontal: no new medicine domain pack, and backend/domain upgrades still require explicit signals.
38
+
39
+ ## 0.3.24 - Beginner MVP draft output
40
+
41
+ - Changed generic local-first beginner requests to return an MVP spec draft from `product_spec_assist` instead of only an interrogation result.
42
+ - Included architecture, data, API, non-goals, and acceptance sections in local-first draft markdown while keeping backend upgrades gated by explicit signals.
43
+ - Added regression coverage so household medicine requests produce a lightweight localStorage MVP draft without registration/admin template pollution.
44
+
45
+ ## 0.3.23 - Visual polish is not backend scope
46
+
47
+ - Clarified that "页面高级一点" affects UI direction and acceptance, not backend/login/database scope.
48
+ - Added local beginner-tool guidance that advanced visual polish remains compatible with `localStorage`.
49
+ - Added regression coverage to prevent agents from treating visual quality as a reason to override local-first architecture.
50
+
51
+ ## 0.3.22 - Beginner default flow guidance
52
+
53
+ - Changed local beginner tool assist results to recommend `spec_compile` with defaults instead of blocking on all questions.
54
+ - Added agent guidance to avoid asking users to answer raw quickQuestions or compact choices like `B + a`.
55
+ - Kept quickQuestions available for structured consumers while encouraging one natural-language confirmation at most.
56
+
57
+ ## 0.3.21 - Local tool signal extraction
58
+
59
+ - Added horizontal signal extraction for beginner local tools without adding new domain packs.
60
+ - Contextualized local-first quick questions with inferred record object, fields, reminders, inventory, and visual requirements.
61
+ - Improved generic local-tool specs and acceptance checks for short requests such as household medicine tracking.
62
+
63
+ ## 0.3.20 - README cleanup
64
+
65
+ - Moved maintainer notes out of the main README flow.
66
+ - Replaced npm-rendered relative docs links with a GitHub maintainer link.
67
+ - Removed client-specific WorkBuddy wording from the public README introduction.
68
+
69
+ ## 0.3.19 - Local-first Gate release candidate
70
+
71
+ - Added a shared `technicalProfile` across assist, interrogation, compile, architecture, and acceptance outputs.
72
+ - Changed product planning to classify technical complexity before business domain matching.
73
+ - Defaulted beginner/local tools to static pages, browser storage, JSON import/export, or `data.json` pages.
74
+ - Preserved backend/domain handling for registration, AI SaaS, digital commerce, and knowledge-base scenarios.
75
+ - Added beginner-friendly examples to clarification questions.
76
+ - Added black-box MCP regression coverage for local-first and reverse-domain scenarios.
77
+ - Fixed publish/test ordering so fresh clones build `dist/index.cjs` before black-box tests run.
package/README.md CHANGED
@@ -19,13 +19,16 @@
19
19
 
20
20
  **不确定用哪个工具?** 先用 `product_spec_assist`,它会自动识别场景并调用合适的工具。
21
21
 
22
+ **想启用在线 PM Gate?** 先用 `product_spec_connect`,它会引导用户打开连接页、下载连接文件,并让当前 Agent 把 token 写入 MCP 配置。
23
+
22
24
  ## Features
23
25
 
24
- This MCP Server provides 7 tools for product development workflow:
26
+ This MCP Server provides 8 tools for product development workflow:
25
27
 
26
28
  | Tool | Description |
27
29
  |------|-------------|
28
30
  | `product_spec_assist` | **推荐入口** - 根据用户原话自动识别场景并调用对应能力 |
31
+ | `product_spec_connect` | **在线增强连接** - 引导用户下载连接文件,并生成当前 Agent 应写入的 MCP 环境变量 |
29
32
  | `spec_interrogate` | Analyze requirement completeness and generate clarification questions |
30
33
  | `spec_compile` | Compile full product specification and development prompt |
31
34
  | `architecture_decide` | Make architecture decisions based on product type and features |
@@ -58,6 +61,8 @@ npm run dev
58
61
 
59
62
  默认只使用本地 PM Gate。需要让低置信或冲突需求走在线 LLM 辅助归门时,可以配置独立 HTTP gate:
60
63
 
64
+ 对普通用户,推荐让 Agent 调用 `product_spec_connect`。用户只需要打开连接页,点击下载 `product-spec-mcp-connect.json`,再把文件发回 Agent;Agent 读取文件后把其中的 `instructions.env` 写入当前 MCP 配置即可。
65
+
61
66
  ```bash
62
67
  PRODUCT_SPEC_REMOTE_GATE_URL=https://gate.example.com/v1/pm-intent
63
68
  PRODUCT_SPEC_REMOTE_GATE_TOKEN=replace-with-token
@@ -66,7 +71,7 @@ PRODUCT_SPEC_REMOTE_GATE_MODE=auto
66
71
  PRODUCT_SPEC_TELEMETRY=off
67
72
  ```
68
73
 
69
- `auto` 模式只在本地规则低置信、unknown 或冲突时调用远程。远程失败、限流、超时或 schema 错误时会自动降级到本地判断。Cloudflare Workers 部署模板随 npm 包一起发布,见 `docs/online-pm-gate.md`。
74
+ `auto` 模式只在本地规则低置信、unknown 或冲突时调用远程。远程失败、限流、超时或 schema 错误时会自动降级到本地判断。Cloudflare Workers 部署模板随 npm 包一起发布,见 `docs/online-pm-gate.md` 和 `docs/connect-flow.md`。
70
75
 
71
76
  ## MCP Client Configuration
72
77
 
@@ -181,6 +186,41 @@ Client-specific integration notes are intentionally kept out of the main user fl
181
186
 
182
187
  ---
183
188
 
189
+ ### product_spec_connect
190
+
191
+ 引导用户连接在线 PM Gate。未配置时返回连接页面;收到连接文件后返回当前 Agent 应写入 MCP 配置的环境变量。
192
+
193
+ **Input:**
194
+ - `connect_file`: 用户从连接页下载的 `product-spec-mcp-connect.json` 内容
195
+ - `client`: 当前 Agent 名称,例如 `workbuddy`、`codex`、`opencode`
196
+
197
+ **Example:**
198
+ ```json
199
+ {
200
+ "client": "workbuddy"
201
+ }
202
+ ```
203
+
204
+ 如果用户已经上传连接文件:
205
+
206
+ ```json
207
+ {
208
+ "client": "workbuddy",
209
+ "connect_file": {
210
+ "type": "product-spec-mcp-connect",
211
+ "instructions": {
212
+ "env": {
213
+ "PRODUCT_SPEC_REMOTE_GATE_URL": "https://productmcp.opc-mind.top/v1/pm-intent",
214
+ "PRODUCT_SPEC_REMOTE_GATE_TOKEN": "psm_xxx",
215
+ "PRODUCT_SPEC_REMOTE_GATE_MODE": "auto"
216
+ }
217
+ }
218
+ }
219
+ }
220
+ ```
221
+
222
+ ---
223
+
184
224
  ### spec_interrogate
185
225
 
186
226
  Analyze requirement completeness and generate clarification questions.
package/dist/index.cjs CHANGED
@@ -28252,6 +28252,116 @@ var routes = ["spec_compile", "spec_interrogate", "architecture_decide"];
28252
28252
  var confidences = ["high", "medium", "low"];
28253
28253
  var sources = ["local_rule", "online_llm", "merged"];
28254
28254
 
28255
+ // src/core/connectGuide.ts
28256
+ var DEFAULT_CONNECT_URL = "https://productmcp.opc-mind.top/connect";
28257
+ function buildConnectGuide(connectFile, client = "unknown") {
28258
+ const connectUrl = process.env.PRODUCT_SPEC_CONNECT_URL || DEFAULT_CONNECT_URL;
28259
+ const currentEnv = currentRemoteGateEnv();
28260
+ if (isRemoteGateConfigured() && !connectFile) {
28261
+ return {
28262
+ configured: true,
28263
+ connectUrl,
28264
+ env: currentEnv,
28265
+ steps: [
28266
+ "\u5F53\u524D product-spec MCP \u5DF2\u914D\u7F6E\u5728\u7EBF PM Gate\u3002",
28267
+ "\u7EE7\u7EED\u6B63\u5E38\u4F7F\u7528 product_spec_assist\uFF1B\u4F4E\u7F6E\u4FE1\u6216\u51B2\u7A81\u9700\u6C42\u4F1A\u81EA\u52A8\u5C1D\u8BD5\u5728\u7EBF\u5224\u65AD\u3002"
28268
+ ],
28269
+ warnings: []
28270
+ };
28271
+ }
28272
+ const parsed = parseConnectFile(connectFile);
28273
+ if (parsed.env) {
28274
+ return {
28275
+ configured: false,
28276
+ connectUrl,
28277
+ env: parsed.env,
28278
+ steps: [
28279
+ `\u68C0\u6D4B\u5230\u8FDE\u63A5\u6587\u4EF6\u3002\u8BF7\u628A env \u5199\u5165\u5F53\u524D ${client || "Agent"} \u7684 product-spec-mcp \u914D\u7F6E\u3002`,
28280
+ "\u4FDD\u5B58\u914D\u7F6E\u540E\uFF0C\u91CD\u542F\u6216\u5237\u65B0 MCP Server\u3002",
28281
+ "\u91CD\u542F\u540E\u518D\u6B21\u8C03\u7528 product_spec_connect\uFF0C\u786E\u8BA4 configured=true\u3002"
28282
+ ],
28283
+ warnings: parsed.warnings
28284
+ };
28285
+ }
28286
+ return {
28287
+ configured: false,
28288
+ connectUrl,
28289
+ steps: [
28290
+ `\u6253\u5F00 ${connectUrl}`,
28291
+ "\u70B9\u51FB\u201C\u751F\u6210\u5E76\u4E0B\u8F7D\u8FDE\u63A5\u6587\u4EF6\u201D\u3002",
28292
+ "\u628A\u4E0B\u8F7D\u7684 product-spec-mcp-connect.json \u53D1\u56DE\u5F53\u524D Agent \u5BF9\u8BDD\u3002",
28293
+ "\u8BA9 Agent \u8BFB\u53D6\u8FDE\u63A5\u6587\u4EF6\uFF0C\u5E76\u628A instructions.env \u5199\u5165\u5F53\u524D MCP \u914D\u7F6E\u3002"
28294
+ ],
28295
+ warnings: [
28296
+ "\u4E0D\u8981\u624B\u52A8\u586B\u5199 token\uFF1B\u8FDE\u63A5\u6587\u4EF6\u4E2D\u5DF2\u7ECF\u5305\u542B\u6240\u9700\u914D\u7F6E\u3002",
28297
+ "\u6D4F\u89C8\u5668\u9875\u9762\u4E0D\u80FD\u76F4\u63A5\u4FEE\u6539\u672C\u673A Agent \u914D\u7F6E\uFF0C\u9700\u8981\u628A\u8FDE\u63A5\u6587\u4EF6\u4EA4\u7ED9 Agent \u5B8C\u6210\u3002"
28298
+ ]
28299
+ };
28300
+ }
28301
+ function isRemoteGateConfigured() {
28302
+ return Boolean(process.env.PRODUCT_SPEC_REMOTE_GATE_URL && process.env.PRODUCT_SPEC_REMOTE_GATE_TOKEN);
28303
+ }
28304
+ function currentRemoteGateEnv() {
28305
+ const env = {};
28306
+ for (const key of [
28307
+ "PRODUCT_SPEC_REMOTE_GATE_URL",
28308
+ "PRODUCT_SPEC_REMOTE_GATE_TOKEN",
28309
+ "PRODUCT_SPEC_REMOTE_GATE_MODE",
28310
+ "PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS",
28311
+ "PRODUCT_SPEC_TELEMETRY"
28312
+ ]) {
28313
+ if (process.env[key]) env[key] = key === "PRODUCT_SPEC_REMOTE_GATE_TOKEN" ? "[CONFIGURED]" : String(process.env[key]);
28314
+ }
28315
+ return env;
28316
+ }
28317
+ function parseConnectFile(connectFile) {
28318
+ if (!connectFile) return { warnings: [] };
28319
+ const warnings = [];
28320
+ if (connectFile.type !== "product-spec-mcp-connect") {
28321
+ warnings.push("\u8FDE\u63A5\u6587\u4EF6 type \u4E0D\u662F product-spec-mcp-connect\uFF0C\u8BF7\u786E\u8BA4\u6587\u4EF6\u6765\u6E90\u3002");
28322
+ }
28323
+ const instructions = getRecord(connectFile.instructions);
28324
+ const env = getRecord(instructions?.env);
28325
+ if (env) {
28326
+ const normalized = normalizeEnv(env);
28327
+ if (normalized.PRODUCT_SPEC_REMOTE_GATE_URL && normalized.PRODUCT_SPEC_REMOTE_GATE_TOKEN) {
28328
+ return { env: normalized, warnings };
28329
+ }
28330
+ }
28331
+ const remoteGate = getRecord(connectFile.remoteGate);
28332
+ const url = asString(remoteGate?.url);
28333
+ const token = asString(remoteGate?.token);
28334
+ if (url && token) {
28335
+ return {
28336
+ env: {
28337
+ PRODUCT_SPEC_REMOTE_GATE_URL: url,
28338
+ PRODUCT_SPEC_REMOTE_GATE_TOKEN: token,
28339
+ PRODUCT_SPEC_REMOTE_GATE_MODE: asString(remoteGate?.mode) || "auto",
28340
+ PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS: String(remoteGate?.timeoutMs || "10000"),
28341
+ PRODUCT_SPEC_TELEMETRY: asString(remoteGate?.telemetry) || "off"
28342
+ },
28343
+ warnings
28344
+ };
28345
+ }
28346
+ warnings.push("\u8FDE\u63A5\u6587\u4EF6\u7F3A\u5C11 PRODUCT_SPEC_REMOTE_GATE_URL \u6216 PRODUCT_SPEC_REMOTE_GATE_TOKEN\u3002");
28347
+ return { warnings };
28348
+ }
28349
+ function normalizeEnv(env) {
28350
+ const normalized = {};
28351
+ for (const [key, value] of Object.entries(env)) {
28352
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
28353
+ normalized[key] = String(value);
28354
+ }
28355
+ }
28356
+ return normalized;
28357
+ }
28358
+ function getRecord(value) {
28359
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
28360
+ }
28361
+ function asString(value) {
28362
+ return typeof value === "string" ? value : "";
28363
+ }
28364
+
28255
28365
  // src/core/assistEngine.ts
28256
28366
  function executeAssist(message, knownContext, preferredPlatform = "unknown", strictness = "normal", autoExecute = true) {
28257
28367
  const routed = routeIntent(message);
@@ -28273,7 +28383,7 @@ async function executeAssistWithRemoteGate(message, knownContext, preferredPlatf
28273
28383
  const result = executeAssist(message, knownContext, preferredPlatform, strictness, autoExecute);
28274
28384
  if (result.routedIntent.scenario !== "build_product" || !result.pmIntentDecision) return result;
28275
28385
  const remote = await callRemotePmIntentGate(message, knownContext || {}, result.pmIntentDecision);
28276
- if (!remote) return result;
28386
+ if (!remote) return appendConnectHintIfUseful(result);
28277
28387
  const merged = remote.decision;
28278
28388
  if (merged.needType !== result.pmIntentDecision.needType && ["multi_user_collaboration", "content_marketing_site", "data_visualization_site"].includes(merged.needType)) {
28279
28389
  const technicalProfile = buildTechnicalProfile(message, knownContext || {});
@@ -28289,7 +28399,7 @@ async function executeAssistWithRemoteGate(message, knownContext, preferredPlatf
28289
28399
  remote.meta.fallbackReason
28290
28400
  );
28291
28401
  }
28292
- return appendRemoteGateMeta({ ...result, pmIntentDecision: merged }, remote.meta.fallbackReason);
28402
+ return appendConnectHintIfUseful(appendRemoteGateMeta({ ...result, pmIntentDecision: merged }, remote.meta.fallbackReason));
28293
28403
  }
28294
28404
  function detectPlatform(message, preferred) {
28295
28405
  if (preferred !== "unknown") return preferred;
@@ -28446,6 +28556,19 @@ function appendRemoteGateMeta(result, fallbackReason) {
28446
28556
  ]
28447
28557
  };
28448
28558
  }
28559
+ function appendConnectHintIfUseful(result) {
28560
+ const decision = result.pmIntentDecision;
28561
+ if (!decision || isRemoteGateConfigured()) return result;
28562
+ const shouldHint = decision.confidence === "low" || decision.needType === "unknown" || decision.technicalShape === "unknown";
28563
+ if (!shouldHint) return result;
28564
+ return {
28565
+ ...result,
28566
+ agentGuidance: [
28567
+ ...result.agentGuidance,
28568
+ "\u5728\u7EBF PM Gate \u672A\u8FDE\u63A5\uFF1B\u5982\u9700\u63D0\u5347\u4F4E\u7F6E\u4FE1\u9700\u6C42\u7684\u5F52\u95E8\u8D28\u91CF\uFF0C\u53EF\u5148\u8C03\u7528 product_spec_connect\uFF0C\u5F15\u5BFC\u7528\u6237\u4E0B\u8F7D\u8FDE\u63A5\u6587\u4EF6\u5E76\u7531 Agent \u5199\u5165 MCP \u914D\u7F6E\u3002"
28569
+ ]
28570
+ };
28571
+ }
28449
28572
  function buildPmGateInterrogateResult(message, routed, technicalProfile, pmIntentDecision, quickQuestions) {
28450
28573
  const title = formatNeedTypeTitle(pmIntentDecision.needType);
28451
28574
  return {
@@ -29972,11 +30095,66 @@ function registerProductSpecAssist(server) {
29972
30095
  );
29973
30096
  }
29974
30097
 
30098
+ // src/schemas/productSpecConnect.schema.ts
30099
+ var ProductSpecConnectInputSchema = external_exports.object({
30100
+ connect_file: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe("\u7528\u6237\u4ECE /connect \u4E0B\u8F7D\u7684 product-spec-mcp-connect.json \u5185\u5BB9"),
30101
+ client: external_exports.string().optional().describe("\u5F53\u524D Agent \u6216 MCP \u5BA2\u6237\u7AEF\u540D\u79F0\uFF0C\u4F8B\u5982 workbuddy\u3001claude_desktop\u3001cursor\u3001codex\u3001unknown")
30102
+ });
30103
+
30104
+ // src/schemas/outputs/productSpecConnect.output.ts
30105
+ var ProductSpecConnectOutputSchema = external_exports.object({
30106
+ configured: external_exports.boolean(),
30107
+ connectUrl: external_exports.string(),
30108
+ env: external_exports.record(external_exports.string()).optional(),
30109
+ steps: external_exports.array(external_exports.string()),
30110
+ warnings: external_exports.array(external_exports.string())
30111
+ });
30112
+
30113
+ // src/tools/productSpecConnect.ts
30114
+ function registerProductSpecConnect(server) {
30115
+ const handler = async (input) => {
30116
+ const result = buildConnectGuide(input.connect_file, input.client || "unknown");
30117
+ return {
30118
+ content: [{ type: "text", text: formatConnectGuide(result) }],
30119
+ structuredContent: result
30120
+ };
30121
+ };
30122
+ server.registerTool(
30123
+ "product_spec_connect",
30124
+ {
30125
+ title: "\u8FDE\u63A5\u5728\u7EBF PM Gate",
30126
+ description: "\u5F15\u5BFC\u7528\u6237\u8FDE\u63A5 product-spec MCP \u5728\u7EBF PM Gate\u3002\u672A\u914D\u7F6E\u65F6\u8FD4\u56DE /connect \u4E0B\u8F7D\u8FDE\u63A5\u6587\u4EF6\uFF1B\u6536\u5230\u8FDE\u63A5\u6587\u4EF6\u540E\u8FD4\u56DE\u5E94\u5199\u5165\u5F53\u524D Agent MCP \u914D\u7F6E\u7684\u73AF\u5883\u53D8\u91CF\u3002",
30127
+ inputSchema: ProductSpecConnectInputSchema.shape,
30128
+ outputSchema: ProductSpecConnectOutputSchema.shape
30129
+ },
30130
+ handler
30131
+ );
30132
+ }
30133
+ function formatConnectGuide(result) {
30134
+ const lines = [
30135
+ "# product-spec MCP \u5728\u7EBF\u589E\u5F3A\u8FDE\u63A5",
30136
+ "",
30137
+ `- **\u5F53\u524D\u72B6\u6001:** ${result.configured ? "\u5DF2\u914D\u7F6E" : "\u672A\u914D\u7F6E\u6216\u5F85\u5199\u5165\u914D\u7F6E"}`,
30138
+ `- **\u8FDE\u63A5\u9875\u9762:** ${result.connectUrl}`,
30139
+ "",
30140
+ "## \u4E0B\u4E00\u6B65",
30141
+ "",
30142
+ ...result.steps.map((step, index) => `${index + 1}. ${step}`)
30143
+ ];
30144
+ if (result.env) {
30145
+ lines.push("", "## \u9700\u8981\u5199\u5165 MCP \u914D\u7F6E\u7684\u73AF\u5883\u53D8\u91CF", "", "```json", JSON.stringify(result.env, null, 2), "```");
30146
+ }
30147
+ if (result.warnings.length > 0) {
30148
+ lines.push("", "## \u6CE8\u610F", "", ...result.warnings.map((warning) => `- ${warning}`));
30149
+ }
30150
+ return lines.join("\n");
30151
+ }
30152
+
29975
30153
  // src/server.ts
29976
30154
  function createServer() {
29977
30155
  const server = new McpServer({
29978
30156
  name: "product-spec-mcp",
29979
- version: "0.3.34"
30157
+ version: "0.4.0"
29980
30158
  });
29981
30159
  registerSpecInterrogate(server);
29982
30160
  registerSpecCompile(server);
@@ -29985,6 +30163,7 @@ function createServer() {
29985
30163
  registerDebugGuide(server);
29986
30164
  registerAcceptanceGenerate(server);
29987
30165
  registerProductSpecAssist(server);
30166
+ registerProductSpecConnect(server);
29988
30167
  return server;
29989
30168
  }
29990
30169
 
@@ -0,0 +1,152 @@
1
+ # product-spec MCP 0.4 Connect Flow
2
+
3
+ 目标:让普通用户不用手抄 token,也不用理解不同 Agent 的 MCP 配置格式。用户只做三步:
4
+
5
+ 1. Agent 提示打开连接页。
6
+ 2. 用户点击下载 `product-spec-mcp-connect.json`。
7
+ 3. 用户把连接文件发回 Agent,由 Agent 写入 MCP 配置。
8
+
9
+ ## User Flow
10
+
11
+ Agent 侧先调用:
12
+
13
+ ```json
14
+ {
15
+ "tool": "product_spec_connect",
16
+ "arguments": {
17
+ "client": "current-agent-name"
18
+ }
19
+ }
20
+ ```
21
+
22
+ MCP 返回连接页,默认是:
23
+
24
+ ```text
25
+ https://productmcp.opc-mind.top/connect
26
+ ```
27
+
28
+ 用户打开页面后点击“生成并下载连接文件”。页面调用 Worker:
29
+
30
+ ```http
31
+ POST /v1/connect-token
32
+ ```
33
+
34
+ Worker 会创建一个 `psm_` 开头的专属 token,并返回连接文件。
35
+
36
+ ## Connect File
37
+
38
+ 下载文件名:
39
+
40
+ ```text
41
+ product-spec-mcp-connect.json
42
+ ```
43
+
44
+ 文件格式:
45
+
46
+ ```json
47
+ {
48
+ "type": "product-spec-mcp-connect",
49
+ "version": 1,
50
+ "remoteGate": {
51
+ "url": "https://productmcp.opc-mind.top/v1/pm-intent",
52
+ "token": "psm_xxx",
53
+ "mode": "auto",
54
+ "timeoutMs": 10000,
55
+ "telemetry": "off"
56
+ },
57
+ "instructions": {
58
+ "summary": "请把 remoteGate 配置写入当前 Agent 的 product-spec-mcp 环境变量。",
59
+ "env": {
60
+ "PRODUCT_SPEC_REMOTE_GATE_URL": "https://productmcp.opc-mind.top/v1/pm-intent",
61
+ "PRODUCT_SPEC_REMOTE_GATE_TOKEN": "psm_xxx",
62
+ "PRODUCT_SPEC_REMOTE_GATE_MODE": "auto",
63
+ "PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS": "10000",
64
+ "PRODUCT_SPEC_TELEMETRY": "off"
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ Agent 收到文件后,再调用:
71
+
72
+ ```json
73
+ {
74
+ "tool": "product_spec_connect",
75
+ "arguments": {
76
+ "client": "current-agent-name",
77
+ "connect_file": {}
78
+ }
79
+ }
80
+ ```
81
+
82
+ 把真实文件 JSON 放到 `connect_file`。MCP 会返回应写入当前 Agent MCP 配置的 `env`。
83
+
84
+ ## Agent Responsibility
85
+
86
+ Agent 应做的事:
87
+
88
+ - 读取 `instructions.env`。
89
+ - 写入当前 Agent 的 product-spec MCP server 配置。
90
+ - 重启或刷新 MCP server。
91
+ - 再调用 `product_spec_connect` 验证 `configured=true`。
92
+
93
+ Agent 不应该做的事:
94
+
95
+ - 不要让用户手抄 token。
96
+ - 不要把 token 打印到公开日志。
97
+ - 不要把连接文件提交到 Git。
98
+ - 不要把 `psm_` token 写入项目源码。
99
+
100
+ ## Worker Endpoints
101
+
102
+ ```http
103
+ GET /connect
104
+ POST /v1/connect-token
105
+ POST /v1/pm-intent
106
+ GET /health
107
+ ```
108
+
109
+ `/v1/pm-intent` 同时接受两类 token:
110
+
111
+ - 旧的全局 `GATE_TOKEN`,用于内部验证和兼容老配置。
112
+ - 新的 `psm_` token,用于用户自助连接和后续计量。
113
+
114
+ ## Storage
115
+
116
+ D1 新增两张表:
117
+
118
+ ```sql
119
+ api_tokens
120
+ usage_events
121
+ ```
122
+
123
+ `api_tokens` 存 token hash,不存明文 token。`usage_events` 记录按 token 的 LLM 使用情况,后续可以接入计费、月额度、封禁和用户面板。
124
+
125
+ ## Quotas
126
+
127
+ 默认每日额度仍由 Worker 变量控制:
128
+
129
+ ```toml
130
+ DAILY_LLM_LIMIT = "20"
131
+ ```
132
+
133
+ 连接页生成的新 token 默认继承 `DAILY_LLM_LIMIT`。如果需要给连接 token 单独设置每日额度,可配置:
134
+
135
+ ```toml
136
+ CONNECT_TOKEN_DAILY_LIMIT = "20"
137
+ ```
138
+
139
+ 如果需要月额度,可配置:
140
+
141
+ ```toml
142
+ CONNECT_TOKEN_MONTHLY_LIMIT = "600"
143
+ ```
144
+
145
+ 改这些值只需要重新部署 Worker,或在 Cloudflare Dashboard 修改 Worker 环境变量,不需要发布 npm。
146
+
147
+ ## Security Notes
148
+
149
+ - 连接文件包含访问 token,只应交给当前 Agent。
150
+ - Worker 只把 token hash 写入 D1。
151
+ - MCP 本地包不内置任何用户 token。
152
+ - 远程失败、限流或超时时,本地 MCP 会降级到本地 PM Gate。
@@ -76,10 +76,37 @@ Runtime behavior:
76
76
  - Prompt cache key: `cache:{model}:{promptHash}:pm-gate-v1`
77
77
  - Cache TTL: 7 days
78
78
  - LLM quota: `DAILY_LLM_LIMIT` non-cached LLM decisions per IP per Shanghai calendar day. Default: 20.
79
+ - Self-serve token quota: `CONNECT_TOKEN_DAILY_LIMIT` can override the daily limit for newly generated `psm_` tokens. If omitted, it inherits `DAILY_LLM_LIMIT`.
80
+ - Optional monthly token quota: `CONNECT_TOKEN_MONTHLY_LIMIT` limits monthly non-cached LLM calls per token.
79
81
  - User message sent to LLM: max 500 characters
80
82
  - LLM max output tokens: 600
81
83
  - LLM temperature: 0.1
82
84
 
85
+ ## Self-Serve Connect Flow
86
+
87
+ 0.4 版本新增浏览器连接页,给非技术用户使用:
88
+
89
+ ```http
90
+ GET /connect
91
+ POST /v1/connect-token
92
+ ```
93
+
94
+ 用户打开 `/connect` 后点击下载 `product-spec-mcp-connect.json`。文件里包含当前 Agent 应写入 MCP 配置的环境变量:
95
+
96
+ ```json
97
+ {
98
+ "PRODUCT_SPEC_REMOTE_GATE_URL": "https://productmcp.opc-mind.top/v1/pm-intent",
99
+ "PRODUCT_SPEC_REMOTE_GATE_TOKEN": "psm_xxx",
100
+ "PRODUCT_SPEC_REMOTE_GATE_MODE": "auto",
101
+ "PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS": "10000",
102
+ "PRODUCT_SPEC_TELEMETRY": "off"
103
+ }
104
+ ```
105
+
106
+ MCP 侧对应工具是 `product_spec_connect`。Agent 应先调用它拿连接页;用户上传连接文件后,再调用它解析出应写入当前 MCP 配置的 `env`。
107
+
108
+ `psm_` token 会写入 D1 的 `api_tokens` 表,Worker 只存 hash,不存明文 token。调用 `/v1/pm-intent` 时,Worker 仍兼容旧的全局 `GATE_TOKEN`。
109
+
83
110
  ## Change LLM Daily Quota
84
111
 
85
112
  `DAILY_LLM_LIMIT` controls the number of non-cached LLM gate calls allowed per IP per Shanghai calendar day. It is a Worker runtime variable, not an npm package setting.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-spec-mcp",
3
- "version": "0.3.34",
3
+ "version": "0.4.0",
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,9 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/index.cjs",
12
+ "CHANGELOG.md",
12
13
  "README.md",
14
+ "docs/connect-flow.md",
13
15
  "docs/online-pm-gate.md",
14
16
  "workers/pm-intent-gate.mjs",
15
17
  "workers/schema.sql",
@@ -10,12 +10,19 @@ export default {
10
10
  async fetch(request, env) {
11
11
  const url = new URL(request.url);
12
12
  if (request.method === "GET" && url.pathname === "/health") {
13
- return json({ ok: true, gateSchemaVersion: GATE_SCHEMA_VERSION });
13
+ return json({ ok: true, gateSchemaVersion: GATE_SCHEMA_VERSION, connect: true });
14
+ }
15
+ if (request.method === "GET" && url.pathname === "/connect") {
16
+ return html(connectPageHtml(url.origin));
17
+ }
18
+ if (request.method === "POST" && url.pathname === "/v1/connect-token") {
19
+ return createConnectToken(request, env, url.origin);
14
20
  }
15
21
  if (request.method !== "POST" || url.pathname !== "/v1/pm-intent") {
16
22
  return json({ error: "not_found" }, 404);
17
23
  }
18
- if (!isAuthorized(request, env)) {
24
+ const auth = await authorizeRequest(request, env);
25
+ if (!auth.ok) {
19
26
  return json({ error: "unauthorized" }, 401);
20
27
  }
21
28
 
@@ -35,6 +42,7 @@ export default {
35
42
  const ipKey = await rateLimitKey(request, env);
36
43
  const resetAt = nextShanghaiMidnightIso();
37
44
  const dailyLimit = resolveDailyLimit(env);
45
+ const tokenLimit = auth.token ? resolveTokenDailyLimit(auth.token, dailyLimit) : null;
38
46
 
39
47
  if (cached?.decision) {
40
48
  await maybeStoreSample(env, telemetryMode, body, cached.decision, cached.decision, {
@@ -42,6 +50,15 @@ export default {
42
50
  cacheHit: 1,
43
51
  rateLimitStatus: "cache_hit",
44
52
  });
53
+ await maybeStoreUsageEvent(env, auth, {
54
+ llmUsed: 0,
55
+ cacheHit: 1,
56
+ model: llm.model,
57
+ promptTokensApprox: cached.promptTokensApprox || 0,
58
+ completionTokensApprox: cached.completionTokensApprox || 0,
59
+ costUnits: 0,
60
+ });
61
+ const remaining = await combinedRemaining(env, ipKey, dailyLimit, auth.token, tokenLimit);
45
62
  return json({
46
63
  decision: cached.decision,
47
64
  llmGate: {
@@ -53,26 +70,34 @@ export default {
53
70
  cacheHit: true,
54
71
  },
55
72
  rateLimit: {
56
- limit: dailyLimit,
57
- remaining: await remainingForKey(env, ipKey, dailyLimit),
73
+ limit: tokenLimit || dailyLimit,
74
+ remaining,
58
75
  resetAt,
59
76
  },
60
77
  privacy: privacyResult(telemetryMode),
61
78
  });
62
79
  }
63
80
 
64
- const limit = await consumeLimit(env, ipKey, resetAt, dailyLimit);
81
+ const limit = await consumeCombinedLimit(env, ipKey, resetAt, dailyLimit, auth.token, tokenLimit);
65
82
  if (!limit.allowed) {
66
83
  await maybeStoreSample(env, telemetryMode, body, null, body.ruleDecision || {}, {
67
84
  llmUsed: 0,
68
85
  cacheHit: 0,
69
86
  rateLimitStatus: "limited",
70
- fallbackReason: "rate_limited",
87
+ fallbackReason: limit.reason || "rate_limited",
88
+ });
89
+ await maybeStoreUsageEvent(env, auth, {
90
+ llmUsed: 0,
91
+ cacheHit: 0,
92
+ model: llm.model,
93
+ promptTokensApprox: 0,
94
+ completionTokensApprox: 0,
95
+ costUnits: 0,
71
96
  });
72
97
  return json({
73
98
  decision: fallbackDecision(body.ruleDecision),
74
99
  llmGate: { used: false, provider: llm.provider, model: llm.model, cacheHit: false },
75
- rateLimit: { limit: dailyLimit, remaining: 0, resetAt },
100
+ rateLimit: { limit: tokenLimit || dailyLimit, remaining: 0, resetAt },
76
101
  privacy: privacyResult(telemetryMode),
77
102
  }, 429);
78
103
  }
@@ -109,6 +134,14 @@ export default {
109
134
  rateLimitStatus: "allowed",
110
135
  fallbackReason,
111
136
  });
137
+ await maybeStoreUsageEvent(env, auth, {
138
+ llmUsed: llmDecision ? 1 : 0,
139
+ cacheHit: 0,
140
+ model: llm.model,
141
+ promptTokensApprox,
142
+ completionTokensApprox,
143
+ costUnits: llmDecision ? 1 : 0,
144
+ });
112
145
 
113
146
  return json({
114
147
  decision: finalDecision,
@@ -122,7 +155,7 @@ export default {
122
155
  ...(fallbackReason ? { fallbackReason } : {}),
123
156
  },
124
157
  rateLimit: {
125
- limit: dailyLimit,
158
+ limit: tokenLimit || dailyLimit,
126
159
  remaining: limit.remaining,
127
160
  resetAt,
128
161
  },
@@ -131,9 +164,28 @@ export default {
131
164
  },
132
165
  };
133
166
 
134
- function isAuthorized(request, env) {
135
- if (!env.GATE_TOKEN) return false;
136
- return request.headers.get("authorization") === `Bearer ${env.GATE_TOKEN}`;
167
+ async function authorizeRequest(request, env) {
168
+ const token = parseBearerToken(request);
169
+ if (!token) return { ok: false, kind: "none" };
170
+ if (env.GATE_TOKEN && token === env.GATE_TOKEN) return { ok: true, kind: "legacy" };
171
+ if (!token.startsWith("psm_")) return { ok: false, kind: "unknown" };
172
+ if (!env.PROMPT_SAMPLES) return { ok: false, kind: "token", reason: "missing_d1" };
173
+ await ensureConnectTables(env);
174
+ const tokenHash = await sha256(token);
175
+ const row = await env.PROMPT_SAMPLES.prepare(
176
+ "SELECT id, token_prefix, daily_limit, monthly_limit, enabled FROM api_tokens WHERE token_hash = ? LIMIT 1"
177
+ ).bind(tokenHash).first();
178
+ if (!row || Number(row.enabled) !== 1) return { ok: false, kind: "token" };
179
+ await env.PROMPT_SAMPLES.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
180
+ .bind(new Date().toISOString(), row.id)
181
+ .run();
182
+ return { ok: true, kind: "token", token: row };
183
+ }
184
+
185
+ function parseBearerToken(request) {
186
+ const header = request.headers.get("authorization") || "";
187
+ const match = header.match(/^Bearer\s+(.+)$/i);
188
+ return match ? match[1].trim() : "";
137
189
  }
138
190
 
139
191
  function buildGatePrompt(message, rule, choices) {
@@ -202,6 +254,183 @@ function resolveDailyLimit(env) {
202
254
  return Math.floor(parsed);
203
255
  }
204
256
 
257
+ function resolveTokenDailyLimit(token, fallback) {
258
+ const parsed = Number(token?.daily_limit || fallback);
259
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
260
+ return Math.floor(parsed);
261
+ }
262
+
263
+ function resolveConnectTokenDailyLimit(env) {
264
+ const parsed = Number(env.CONNECT_TOKEN_DAILY_LIMIT || env.DAILY_LLM_LIMIT || DEFAULT_DAILY_LIMIT);
265
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_DAILY_LIMIT;
266
+ return Math.floor(parsed);
267
+ }
268
+
269
+ async function createConnectToken(request, env, origin) {
270
+ if (!env.PROMPT_SAMPLES) return json({ error: "missing_d1_binding" }, 503);
271
+ await ensureConnectTables(env);
272
+ let body = {};
273
+ try {
274
+ body = await request.json();
275
+ } catch {
276
+ body = {};
277
+ }
278
+ const token = `psm_${randomToken(32)}`;
279
+ const tokenHash = await sha256(token);
280
+ const id = crypto.randomUUID();
281
+ const now = new Date().toISOString();
282
+ const dailyLimit = resolveConnectTokenDailyLimit(env);
283
+ const monthlyLimit = positiveIntegerOrNull(env.CONNECT_TOKEN_MONTHLY_LIMIT);
284
+ const client = sanitizeShortText(body.client || "unknown", 40);
285
+ const label = sanitizeShortText(body.label || `${client} connect token`, 80);
286
+
287
+ await env.PROMPT_SAMPLES.prepare(
288
+ `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)`
291
+ ).bind(
292
+ id,
293
+ tokenHash,
294
+ token.slice(0, 12),
295
+ label,
296
+ dailyLimit,
297
+ monthlyLimit,
298
+ now
299
+ ).run();
300
+
301
+ const remoteGateUrl = resolveRemoteGateUrl(env, origin);
302
+ const connectFile = buildConnectFile(remoteGateUrl, token);
303
+ return json({
304
+ ok: true,
305
+ tokenPrefix: token.slice(0, 12),
306
+ dailyLimit,
307
+ monthlyLimit,
308
+ connectFile,
309
+ });
310
+ }
311
+
312
+ function buildConnectFile(remoteGateUrl, token) {
313
+ const env = {
314
+ PRODUCT_SPEC_REMOTE_GATE_URL: remoteGateUrl,
315
+ PRODUCT_SPEC_REMOTE_GATE_TOKEN: token,
316
+ PRODUCT_SPEC_REMOTE_GATE_MODE: "auto",
317
+ PRODUCT_SPEC_REMOTE_GATE_TIMEOUT_MS: "10000",
318
+ PRODUCT_SPEC_TELEMETRY: "off",
319
+ };
320
+ return {
321
+ type: "product-spec-mcp-connect",
322
+ version: 1,
323
+ remoteGate: {
324
+ url: remoteGateUrl,
325
+ token,
326
+ mode: "auto",
327
+ timeoutMs: 10000,
328
+ telemetry: "off",
329
+ },
330
+ instructions: {
331
+ summary: "请把 remoteGate 配置写入当前 Agent 的 product-spec-mcp 环境变量。",
332
+ env,
333
+ },
334
+ };
335
+ }
336
+
337
+ function connectPageHtml(origin) {
338
+ const apiUrl = `${origin}/v1/connect-token`;
339
+ return `<!doctype html>
340
+ <html lang="zh-CN">
341
+ <head>
342
+ <meta charset="utf-8">
343
+ <meta name="viewport" content="width=device-width, initial-scale=1">
344
+ <title>连接 product-spec MCP</title>
345
+ <style>
346
+ :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
347
+ body { margin: 0; min-height: 100vh; background: #f6f8fb; color: #172033; display: grid; place-items: center; }
348
+ main { width: min(760px, calc(100vw - 32px)); padding: 48px 0; }
349
+ h1 { font-size: 36px; line-height: 1.12; margin: 0 0 16px; letter-spacing: 0; }
350
+ p { font-size: 16px; line-height: 1.7; color: #526071; margin: 0 0 18px; }
351
+ .panel { background: rgba(255,255,255,.86); border: 1px solid #e4e9f1; border-radius: 8px; padding: 28px; box-shadow: 0 18px 48px rgba(25, 38, 64, .10); }
352
+ .steps { display: grid; gap: 12px; margin: 26px 0; padding: 0; list-style: none; }
353
+ .steps li { display: flex; gap: 12px; align-items: flex-start; color: #243246; }
354
+ .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; }
355
+ button { appearance: none; border: 0; border-radius: 8px; background: #0f766e; color: white; font-size: 16px; font-weight: 700; padding: 14px 18px; cursor: pointer; }
356
+ button:disabled { opacity: .65; cursor: wait; }
357
+ .status { margin-top: 16px; font-size: 14px; color: #526071; min-height: 22px; }
358
+ .fine { margin-top: 24px; font-size: 13px; color: #7a8698; }
359
+ </style>
360
+ </head>
361
+ <body>
362
+ <main>
363
+ <section class="panel">
364
+ <h1>连接 product-spec MCP</h1>
365
+ <p>下载连接文件,然后把文件发回给你正在使用的 Agent。Agent 会读取文件并把在线 PM Gate 配置写入当前 MCP 设置。</p>
366
+ <ol class="steps">
367
+ <li><span class="num">1</span><span>点击下方按钮生成你的连接文件。</span></li>
368
+ <li><span class="num">2</span><span>把下载的 <strong>product-spec-mcp-connect.json</strong> 拖回或上传到 Agent 对话。</span></li>
369
+ <li><span class="num">3</span><span>让 Agent 按文件里的说明完成配置并重启 MCP。</span></li>
370
+ </ol>
371
+ <button id="download">生成并下载连接文件</button>
372
+ <div class="status" id="status"></div>
373
+ <p class="fine">连接文件里包含你的专属访问 token,请不要公开分享。默认额度由服务端配置控制。</p>
374
+ </section>
375
+ </main>
376
+ <script>
377
+ const button = document.getElementById("download");
378
+ const status = document.getElementById("status");
379
+ button.addEventListener("click", async () => {
380
+ button.disabled = true;
381
+ status.textContent = "正在生成连接文件...";
382
+ try {
383
+ const response = await fetch(${JSON.stringify(apiUrl)}, {
384
+ method: "POST",
385
+ headers: { "content-type": "application/json" },
386
+ body: JSON.stringify({ client: "browser-connect-page" })
387
+ });
388
+ const payload = await response.json();
389
+ if (!response.ok || !payload.connectFile) throw new Error(payload.error || "connect_failed");
390
+ const blob = new Blob([JSON.stringify(payload.connectFile, null, 2)], { type: "application/json" });
391
+ const url = URL.createObjectURL(blob);
392
+ const a = document.createElement("a");
393
+ a.href = url;
394
+ a.download = "product-spec-mcp-connect.json";
395
+ a.click();
396
+ URL.revokeObjectURL(url);
397
+ status.textContent = "已下载连接文件。请把它发回给你的 Agent。";
398
+ } catch (error) {
399
+ status.textContent = "生成失败,请稍后重试。";
400
+ } finally {
401
+ button.disabled = false;
402
+ }
403
+ });
404
+ </script>
405
+ </body>
406
+ </html>`;
407
+ }
408
+
409
+ function resolveRemoteGateUrl(env, origin) {
410
+ return String(env.PUBLIC_REMOTE_GATE_URL || `${origin}/v1/pm-intent`);
411
+ }
412
+
413
+ function randomToken(byteLength) {
414
+ const bytes = new Uint8Array(byteLength);
415
+ crypto.getRandomValues(bytes);
416
+ let raw = "";
417
+ for (const byte of bytes) raw += String.fromCharCode(byte);
418
+ return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
419
+ }
420
+
421
+ function positiveIntegerOrNull(value) {
422
+ const parsed = Number(value);
423
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
424
+ return Math.floor(parsed);
425
+ }
426
+
427
+ function sanitizeShortText(value, maxLength) {
428
+ return String(value || "")
429
+ .replace(/[^\w\s.@:-]/g, "")
430
+ .trim()
431
+ .slice(0, maxLength) || "product-spec-mcp";
432
+ }
433
+
205
434
  async function callOpenAiCompatible(llm, prompt) {
206
435
  if (!llm.apiKey) throw new Error(`missing_${llm.provider}_api_key`);
207
436
  const response = await fetch(`${normalizeBaseUrl(llm.baseUrl)}/chat/completions`, {
@@ -366,6 +595,48 @@ async function remainingForKey(env, key, dailyLimit) {
366
595
  return Math.max(0, dailyLimit - current);
367
596
  }
368
597
 
598
+ async function consumeCombinedLimit(env, ipKey, resetAt, dailyLimit, token, tokenLimit) {
599
+ const ipLimit = await consumeLimit(env, ipKey, resetAt, dailyLimit);
600
+ if (!ipLimit.allowed) return { allowed: false, remaining: 0, reason: "ip_rate_limited" };
601
+ if (!token || !tokenLimit) return ipLimit;
602
+
603
+ const monthly = await checkMonthlyLimit(env, token);
604
+ if (!monthly.allowed) return { allowed: false, remaining: 0, reason: "token_monthly_limited" };
605
+
606
+ const tokenKey = tokenRateLimitKey(token.id);
607
+ const tokenDaily = await consumeLimit(env, tokenKey, resetAt, tokenLimit);
608
+ if (!tokenDaily.allowed) return { allowed: false, remaining: 0, reason: "token_daily_limited" };
609
+
610
+ return {
611
+ allowed: true,
612
+ remaining: Math.min(ipLimit.remaining, tokenDaily.remaining, monthly.remaining ?? tokenDaily.remaining),
613
+ };
614
+ }
615
+
616
+ async function combinedRemaining(env, ipKey, dailyLimit, token, tokenLimit) {
617
+ const ipRemaining = await remainingForKey(env, ipKey, dailyLimit);
618
+ if (!token || !tokenLimit) return ipRemaining;
619
+ const tokenRemaining = await remainingForKey(env, tokenRateLimitKey(token.id), tokenLimit);
620
+ const monthly = await checkMonthlyLimit(env, token);
621
+ return Math.min(ipRemaining, tokenRemaining, monthly.remaining ?? tokenRemaining);
622
+ }
623
+
624
+ function tokenRateLimitKey(tokenId) {
625
+ return `token-rate:${shanghaiDateKey()}:${tokenId}`;
626
+ }
627
+
628
+ async function checkMonthlyLimit(env, token) {
629
+ const monthlyLimit = Number(token?.monthly_limit || 0);
630
+ if (!env.PROMPT_SAMPLES || !Number.isFinite(monthlyLimit) || monthlyLimit <= 0) return { allowed: true };
631
+ await ensureConnectTables(env);
632
+ const month = shanghaiDateKey().slice(0, 7);
633
+ const row = await env.PROMPT_SAMPLES.prepare(
634
+ "SELECT COALESCE(SUM(cost_units), 0) AS used FROM usage_events WHERE token_id = ? AND event_month = ?"
635
+ ).bind(token.id, month).first();
636
+ const used = Number(row?.used || 0);
637
+ return { allowed: used < monthlyLimit, remaining: Math.max(0, monthlyLimit - used) };
638
+ }
639
+
369
640
  async function rateLimitKey(request, env) {
370
641
  const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for") || "unknown";
371
642
  const day = shanghaiDateKey();
@@ -373,6 +644,70 @@ async function rateLimitKey(request, env) {
373
644
  return `rate:${day}:${await sha256(`${salt}:${ip}`)}`;
374
645
  }
375
646
 
647
+ async function ensureConnectTables(env) {
648
+ if (!env.PROMPT_SAMPLES) return;
649
+ await env.PROMPT_SAMPLES.prepare(
650
+ `CREATE TABLE IF NOT EXISTS api_tokens (
651
+ id TEXT PRIMARY KEY,
652
+ token_hash TEXT UNIQUE NOT NULL,
653
+ token_prefix TEXT NOT NULL,
654
+ label TEXT,
655
+ daily_limit INTEGER NOT NULL,
656
+ monthly_limit INTEGER,
657
+ enabled INTEGER NOT NULL,
658
+ created_at TEXT NOT NULL,
659
+ last_used_at TEXT
660
+ )`
661
+ ).run();
662
+ await env.PROMPT_SAMPLES.prepare(
663
+ `CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash
664
+ ON api_tokens(token_hash)`
665
+ ).run();
666
+ await env.PROMPT_SAMPLES.prepare(
667
+ `CREATE TABLE IF NOT EXISTS usage_events (
668
+ id TEXT PRIMARY KEY,
669
+ token_id TEXT,
670
+ created_at TEXT NOT NULL,
671
+ event_date TEXT NOT NULL,
672
+ event_month TEXT NOT NULL,
673
+ llm_used INTEGER NOT NULL,
674
+ cache_hit INTEGER NOT NULL,
675
+ model TEXT,
676
+ prompt_tokens_approx INTEGER,
677
+ completion_tokens_approx INTEGER,
678
+ cost_units INTEGER NOT NULL
679
+ )`
680
+ ).run();
681
+ await env.PROMPT_SAMPLES.prepare(
682
+ `CREATE INDEX IF NOT EXISTS idx_usage_events_token_month
683
+ ON usage_events(token_id, event_month)`
684
+ ).run();
685
+ }
686
+
687
+ async function maybeStoreUsageEvent(env, auth, event) {
688
+ if (!env.PROMPT_SAMPLES || !auth?.token) return;
689
+ await ensureConnectTables(env);
690
+ const date = shanghaiDateKey();
691
+ await env.PROMPT_SAMPLES.prepare(
692
+ `INSERT INTO usage_events (
693
+ id, token_id, created_at, event_date, event_month, llm_used, cache_hit, model,
694
+ prompt_tokens_approx, completion_tokens_approx, cost_units
695
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
696
+ ).bind(
697
+ crypto.randomUUID(),
698
+ auth.token.id,
699
+ new Date().toISOString(),
700
+ date,
701
+ date.slice(0, 7),
702
+ event.llmUsed ? 1 : 0,
703
+ event.cacheHit ? 1 : 0,
704
+ event.model || null,
705
+ event.promptTokensApprox || 0,
706
+ event.completionTokensApprox || 0,
707
+ event.costUnits || 0
708
+ ).run();
709
+ }
710
+
376
711
  async function maybeStoreSample(env, telemetryMode, body, llmDecision, finalDecision, meta) {
377
712
  if (!env.PROMPT_SAMPLES || telemetryMode === "off") return;
378
713
  const id = crypto.randomUUID();
@@ -481,6 +816,13 @@ function json(payload, status = 200) {
481
816
  });
482
817
  }
483
818
 
819
+ function html(body, status = 200) {
820
+ return new Response(body, {
821
+ status,
822
+ headers: { "content-type": "text/html; charset=utf-8" },
823
+ });
824
+ }
825
+
484
826
  const needTypes = [
485
827
  "static_display",
486
828
  "personal_local_tool",
@@ -22,3 +22,35 @@ ON prompt_samples(created_at);
22
22
 
23
23
  CREATE INDEX IF NOT EXISTS idx_prompt_samples_message_hash
24
24
  ON prompt_samples(message_hash);
25
+
26
+ CREATE TABLE IF NOT EXISTS api_tokens (
27
+ id TEXT PRIMARY KEY,
28
+ token_hash TEXT UNIQUE NOT NULL,
29
+ token_prefix TEXT NOT NULL,
30
+ label TEXT,
31
+ daily_limit INTEGER NOT NULL,
32
+ monthly_limit INTEGER,
33
+ enabled INTEGER NOT NULL,
34
+ created_at TEXT NOT NULL,
35
+ last_used_at TEXT
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash
39
+ ON api_tokens(token_hash);
40
+
41
+ CREATE TABLE IF NOT EXISTS usage_events (
42
+ id TEXT PRIMARY KEY,
43
+ token_id TEXT,
44
+ created_at TEXT NOT NULL,
45
+ event_date TEXT NOT NULL,
46
+ event_month TEXT NOT NULL,
47
+ llm_used INTEGER NOT NULL,
48
+ cache_hit INTEGER NOT NULL,
49
+ model TEXT,
50
+ prompt_tokens_approx INTEGER,
51
+ completion_tokens_approx INTEGER,
52
+ cost_units INTEGER NOT NULL
53
+ );
54
+
55
+ CREATE INDEX IF NOT EXISTS idx_usage_events_token_month
56
+ ON usage_events(token_id, event_month);