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 +77 -0
- package/README.md +42 -2
- package/dist/index.cjs +182 -3
- package/docs/connect-flow.md +152 -0
- package/docs/online-pm-gate.md +27 -0
- package/package.json +3 -1
- package/workers/pm-intent-gate.mjs +353 -11
- package/workers/schema.sql +32 -0
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
|
|
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.
|
|
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。
|
package/docs/online-pm-gate.md
CHANGED
|
@@ -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
|
+
"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
|
-
|
|
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
|
|
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
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
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",
|
package/workers/schema.sql
CHANGED
|
@@ -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);
|