offerguard-mcp-server 0.2.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/README.md +76 -0
- package/package.json +27 -0
- package/src/offerguard-tools.js +426 -0
- package/src/server.js +229 -0
- package/src/stdio.js +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# OfferGuard MCP Server
|
|
2
|
+
|
|
3
|
+
OfferGuard MCP Server is a real Model Context Protocol service for internship offer risk auditing. It exposes deterministic OfferGuard tools through MCP Streamable HTTP, legacy SSE, and stdio transports.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
- `risk_term_matcher`: matches JD risk terms and returns severity, evidence, and trigger reasons.
|
|
8
|
+
- `internship_compliance_checker`: checks unpaid trials, deposits, training loans, agreements, work hours, salary payment, IP ownership, and relocation risk.
|
|
9
|
+
- `offer_score_calculator`: returns seven-dimension scores, overall score, risk level, and recommendation route.
|
|
10
|
+
|
|
11
|
+
## Local Run
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install
|
|
15
|
+
npm test
|
|
16
|
+
npm start
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Default local HTTP endpoints:
|
|
20
|
+
|
|
21
|
+
- Health: `http://127.0.0.1:3000/health`
|
|
22
|
+
- MCP Streamable HTTP: `http://127.0.0.1:3000/mcp`
|
|
23
|
+
- Legacy SSE: `http://127.0.0.1:3000/sse`
|
|
24
|
+
- Legacy messages: `http://127.0.0.1:3000/messages`
|
|
25
|
+
|
|
26
|
+
Stdio entry for MCP hosting platforms:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx -y offerguard-mcp-server@0.2.0
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## XFYun MCP Hosting
|
|
33
|
+
|
|
34
|
+
Use the XFYun MCP hosting platform to host this package as a tool-type MCP server.
|
|
35
|
+
|
|
36
|
+
Recommended cloud hosting configuration:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
command: npx
|
|
40
|
+
args: -y offerguard-mcp-server@0.2.0
|
|
41
|
+
env: {}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
After hosting, the tool list must show:
|
|
45
|
+
|
|
46
|
+
- `risk_term_matcher`
|
|
47
|
+
- `internship_compliance_checker`
|
|
48
|
+
- `offer_score_calculator`
|
|
49
|
+
|
|
50
|
+
If the hosted MCP detail page does not show these three tool names, do not connect it to the main OfferGuard workflow.
|
|
51
|
+
|
|
52
|
+
## XFYun Workflow Integration
|
|
53
|
+
|
|
54
|
+
Place the hosted MCP tool nodes after the material-completeness gate and before the seven-dimension scorer:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
02_资料完整性门禁
|
|
58
|
+
-> MCP: risk_term_matcher
|
|
59
|
+
-> MCP: internship_compliance_checker
|
|
60
|
+
-> MCP: offer_score_calculator
|
|
61
|
+
-> 04_七维结构化评分器
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The final report must include `MCP工具审计摘要` with:
|
|
65
|
+
|
|
66
|
+
- matched risk terms from `risk_term_matcher.risk_terms`
|
|
67
|
+
- compliance red lines from `internship_compliance_checker.red_lines`
|
|
68
|
+
- score, risk level, and route from `offer_score_calculator`
|
|
69
|
+
|
|
70
|
+
If MCP returns empty data or errors, stop the workflow and show:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
MCP 工具连接失败,暂不能生成 OfferGuard 风险报告。请稍后重试或检查 MCP Server 状态。
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Do not continue with a prompt-only fallback.
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "offerguard-mcp-server",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "OfferGuard MCP Server for internship offer risk auditing.",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node src/server.js",
|
|
9
|
+
"stdio": "node src/stdio.js",
|
|
10
|
+
"test": "node test/mcp-protocol.test.js"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"offerguard-mcp-server": "src/stdio.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.17.5",
|
|
21
|
+
"express": "^5.1.0",
|
|
22
|
+
"zod": "^3.25.76"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
const SEVERITY_WEIGHT = {
|
|
2
|
+
low: 1,
|
|
3
|
+
medium: 2,
|
|
4
|
+
high: 3,
|
|
5
|
+
critical: 4
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const RISK_TERMS = [
|
|
9
|
+
{
|
|
10
|
+
term: "无薪试岗",
|
|
11
|
+
severity: "critical",
|
|
12
|
+
category: "合规红线",
|
|
13
|
+
reason: "试岗或实习期间不支付报酬,学生应优先拒绝或要求书面说明。",
|
|
14
|
+
aliases: ["试岗无薪", "无薪实习", "不发薪", "不支付薪资", "无报酬试用"]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
term: "押金",
|
|
18
|
+
severity: "critical",
|
|
19
|
+
category: "合规红线",
|
|
20
|
+
reason: "要求学生缴纳押金、保证金或类似费用属于高风险信号。",
|
|
21
|
+
aliases: ["保证金", "服装费", "工牌费", "设备押金", "入职收费"]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
term: "培训贷",
|
|
25
|
+
severity: "critical",
|
|
26
|
+
category: "合规红线",
|
|
27
|
+
reason: "把入职、培训或实习机会与贷款绑定,存在明显求职风险。",
|
|
28
|
+
aliases: ["贷款培训", "分期培训", "培训分期"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
term: "薪资面议",
|
|
32
|
+
severity: "medium",
|
|
33
|
+
category: "薪资透明",
|
|
34
|
+
reason: "薪资结构不透明,面试前需要确认日薪、发放周期和绩效占比。",
|
|
35
|
+
aliases: ["待遇面议", "薪酬面议", "工资面议"]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
term: "弹性工作",
|
|
39
|
+
severity: "medium",
|
|
40
|
+
category: "工作强度",
|
|
41
|
+
reason: "弹性工作可能掩盖加班或随时响应要求,需要核实边界。",
|
|
42
|
+
aliases: ["弹性上班", "时间灵活"]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
term: "抗压强",
|
|
46
|
+
severity: "medium",
|
|
47
|
+
category: "工作强度",
|
|
48
|
+
reason: "强调抗压通常意味着强节奏或高不确定性任务。",
|
|
49
|
+
aliases: ["抗压能力强", "能吃苦", "高压环境"]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
term: "狼性文化",
|
|
53
|
+
severity: "high",
|
|
54
|
+
category: "组织文化",
|
|
55
|
+
reason: "狼性、强结果导向等表述可能对应高压管理和工作边界模糊。",
|
|
56
|
+
aliases: ["狼性", "强结果导向", "末位淘汰"]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
term: "快速成长",
|
|
60
|
+
severity: "low",
|
|
61
|
+
category: "成长承诺",
|
|
62
|
+
reason: "快速成长本身不是风险,但若缺少导师和产出机制,容易变成空泛承诺。",
|
|
63
|
+
aliases: ["高速成长", "成长空间大"]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
term: "加班",
|
|
67
|
+
severity: "medium",
|
|
68
|
+
category: "工作强度",
|
|
69
|
+
reason: "需要确认加班频率、调休、补贴以及是否影响课程安排。",
|
|
70
|
+
aliases: ["晚间在线", "周末响应", "随叫随到", "996", "大小周"]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
term: "转正不明",
|
|
74
|
+
severity: "medium",
|
|
75
|
+
category: "转正路径",
|
|
76
|
+
reason: "转正条件不清晰会影响学生对机会成本的判断。",
|
|
77
|
+
aliases: ["表现优秀可转正", "有转正机会", "转正另议"]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
term: "职责泛化",
|
|
81
|
+
severity: "medium",
|
|
82
|
+
category: "岗位边界",
|
|
83
|
+
reason: "职责过泛会降低成长确定性,也容易落入杂活型实习。",
|
|
84
|
+
aliases: ["其他临时工作", "完成领导安排", "综合事务", "多任务协同"]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
term: "作品归属公司",
|
|
88
|
+
severity: "high",
|
|
89
|
+
category: "知识产权",
|
|
90
|
+
reason: "作品、代码或账号归属不清会影响简历展示和后续作品集使用。",
|
|
91
|
+
aliases: ["作品版权归公司", "代码归公司", "账号归公司", "不得对外展示"]
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
term: "自带设备",
|
|
95
|
+
severity: "medium",
|
|
96
|
+
category: "资源支持",
|
|
97
|
+
reason: "要求自带电脑或设备,需要确认补贴、数据安全和损坏责任。",
|
|
98
|
+
aliases: ["自带电脑", "自备电脑", "自备设备"]
|
|
99
|
+
}
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const COMPLIANCE_RULES = [
|
|
103
|
+
{
|
|
104
|
+
id: "unpaid_trial",
|
|
105
|
+
label: "无薪试岗/无薪实习",
|
|
106
|
+
severity: "critical",
|
|
107
|
+
patterns: ["无薪试岗", "试岗无薪", "无薪实习", "不发薪", "不支付薪资", "无报酬试用"],
|
|
108
|
+
action: "建议拒绝,除非对方提供合法、明确、书面的报酬与协议安排。"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "deposit_or_fee",
|
|
112
|
+
label: "押金/保证金/入职收费",
|
|
113
|
+
severity: "critical",
|
|
114
|
+
patterns: ["押金", "保证金", "服装费", "工牌费", "设备押金", "入职收费"],
|
|
115
|
+
action: "建议拒绝,不应为获得实习机会提前缴费。"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "training_loan",
|
|
119
|
+
label: "培训贷/分期培训",
|
|
120
|
+
severity: "critical",
|
|
121
|
+
patterns: ["培训贷", "贷款培训", "分期培训", "培训分期"],
|
|
122
|
+
action: "建议拒绝,并保留沟通记录。"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "work_intensity",
|
|
126
|
+
label: "工作时长和响应边界不清",
|
|
127
|
+
severity: "high",
|
|
128
|
+
patterns: ["996", "大小周", "随叫随到", "晚间在线", "周末响应", "高强度加班"],
|
|
129
|
+
action: "必须在面试中确认工作时长、调休和补贴规则。"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "ip_ownership",
|
|
133
|
+
label: "作品/代码/账号归属不清",
|
|
134
|
+
severity: "high",
|
|
135
|
+
patterns: ["作品归属公司", "作品版权归公司", "代码归公司", "账号归公司", "不得对外展示"],
|
|
136
|
+
action: "必须要求写入协议,确认可否用于简历、作品集或面试展示。"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "remote_or_relocation",
|
|
140
|
+
label: "异地到岗/驻场安排",
|
|
141
|
+
severity: "medium",
|
|
142
|
+
patterns: ["异地到岗", "外派", "驻场", "跨城到岗"],
|
|
143
|
+
action: "必须确认住宿、交通、保险和安全支持。"
|
|
144
|
+
}
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const ROLE_KEYWORDS = {
|
|
148
|
+
media: ["新媒体", "运营", "公众号", "小红书", "短视频", "活动策划"],
|
|
149
|
+
backend: ["Java", "后端", "接口", "MySQL", "Redis", "计算机", "开源"],
|
|
150
|
+
product: ["产品", "需求", "原型", "用户研究", "数据分析"],
|
|
151
|
+
design: ["设计", "Figma", "视觉", "作品集", "交互"]
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function normalizeText(value) {
|
|
155
|
+
if (!value) return "";
|
|
156
|
+
if (typeof value === "string") return value;
|
|
157
|
+
return JSON.stringify(value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function clamp(score) {
|
|
161
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function includesAny(text, patterns) {
|
|
165
|
+
return patterns.some(pattern => text.includes(pattern));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extractEvidence(text, pattern) {
|
|
169
|
+
const index = text.indexOf(pattern);
|
|
170
|
+
if (index < 0) return "";
|
|
171
|
+
const start = Math.max(0, index - 24);
|
|
172
|
+
const end = Math.min(text.length, index + pattern.length + 36);
|
|
173
|
+
return text.slice(start, end).replace(/\s+/g, " ").trim();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function findFirstAlias(text, term) {
|
|
177
|
+
const patterns = [term.term, ...term.aliases];
|
|
178
|
+
return patterns.find(pattern => text.includes(pattern));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function countBySeverity(items) {
|
|
182
|
+
return items.reduce(
|
|
183
|
+
(acc, item) => {
|
|
184
|
+
acc[item.severity] += 1;
|
|
185
|
+
return acc;
|
|
186
|
+
},
|
|
187
|
+
{ low: 0, medium: 0, high: 0, critical: 0 }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function highestSeverity(items) {
|
|
192
|
+
return items.reduce((highest, item) => {
|
|
193
|
+
return SEVERITY_WEIGHT[item.severity] > SEVERITY_WEIGHT[highest]
|
|
194
|
+
? item.severity
|
|
195
|
+
: highest;
|
|
196
|
+
}, "low");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function salaryIsExplicit(text) {
|
|
200
|
+
return /(\d{2,4}\s*[-~至到]\s*\d{2,4}\s*元?\/?(天|日|月)?)|(\d{2,4}\s*元\/?(天|日))|(\d{3,5}\s*\/月)/.test(text);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function hasAgreement(text) {
|
|
204
|
+
return includesAny(text, ["实习协议", "三方协议", "劳动合同", "书面协议", "电子签"]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function hasMentor(text) {
|
|
208
|
+
return includesAny(text, ["导师", "带教", "mentor", "主管指导", "代码评审", "培训体系"]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasConversionPath(text) {
|
|
212
|
+
return includesAny(text, ["转正标准", "转正流程", "考核指标", "留用", "转正名额"]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function roleMatchBonus(jdText, studentText) {
|
|
216
|
+
const keywordGroups = Object.values(ROLE_KEYWORDS);
|
|
217
|
+
return keywordGroups.some(keywords => keywords.some(word => jdText.includes(word)) && keywords.some(word => studentText.includes(word)))
|
|
218
|
+
? 10
|
|
219
|
+
: 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function matchRiskTerms(input) {
|
|
223
|
+
const jdText = normalizeText(input.jdText ?? input.text ?? input);
|
|
224
|
+
const matches = RISK_TERMS.map(term => {
|
|
225
|
+
const matched = findFirstAlias(jdText, term);
|
|
226
|
+
if (!matched) return null;
|
|
227
|
+
return {
|
|
228
|
+
term: term.term,
|
|
229
|
+
matched_text: matched,
|
|
230
|
+
severity: term.severity,
|
|
231
|
+
category: term.category,
|
|
232
|
+
evidence: extractEvidence(jdText, matched),
|
|
233
|
+
trigger_reason: term.reason
|
|
234
|
+
};
|
|
235
|
+
}).filter(Boolean);
|
|
236
|
+
|
|
237
|
+
const severity_counts = countBySeverity(matches);
|
|
238
|
+
const penalty = matches.reduce((sum, item) => sum + SEVERITY_WEIGHT[item.severity] * 4, 0);
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
tool: "risk_term_matcher",
|
|
242
|
+
version: "0.2.0",
|
|
243
|
+
risk_terms: matches,
|
|
244
|
+
summary: {
|
|
245
|
+
total_hits: matches.length,
|
|
246
|
+
severity_counts,
|
|
247
|
+
highest_severity: matches.length ? highestSeverity(matches) : "none",
|
|
248
|
+
risk_score_penalty: Math.min(45, penalty)
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function checkInternshipCompliance(input) {
|
|
254
|
+
const text = [input.jdText, input.offerText, input.extraText]
|
|
255
|
+
.map(normalizeText)
|
|
256
|
+
.filter(Boolean)
|
|
257
|
+
.join("\n");
|
|
258
|
+
|
|
259
|
+
const flags = COMPLIANCE_RULES.map(rule => {
|
|
260
|
+
const matched = rule.patterns.find(pattern => text.includes(pattern));
|
|
261
|
+
if (!matched) return null;
|
|
262
|
+
return {
|
|
263
|
+
id: rule.id,
|
|
264
|
+
label: rule.label,
|
|
265
|
+
severity: rule.severity,
|
|
266
|
+
matched_text: matched,
|
|
267
|
+
evidence: extractEvidence(text, matched),
|
|
268
|
+
recommended_action: rule.action
|
|
269
|
+
};
|
|
270
|
+
}).filter(Boolean);
|
|
271
|
+
|
|
272
|
+
const missing_items = [];
|
|
273
|
+
if (!salaryIsExplicit(text) || includesAny(text, ["薪资面议", "待遇面议", "工资面议"])) {
|
|
274
|
+
missing_items.push({
|
|
275
|
+
id: "salary_structure",
|
|
276
|
+
label: "薪资结构/发放周期",
|
|
277
|
+
severity: "medium",
|
|
278
|
+
recommended_question: "请问日薪或月薪、发放时间、绩效占比和是否有扣款规则分别是什么?"
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (!hasAgreement(text)) {
|
|
282
|
+
missing_items.push({
|
|
283
|
+
id: "written_agreement",
|
|
284
|
+
label: "书面实习协议",
|
|
285
|
+
severity: "high",
|
|
286
|
+
recommended_question: "入职前是否签署书面实习协议,协议中是否包含薪资、工时、保险和作品归属?"
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
if (!hasConversionPath(text) && includesAny(text, ["转正", "留用"])) {
|
|
290
|
+
missing_items.push({
|
|
291
|
+
id: "conversion_criteria",
|
|
292
|
+
label: "转正标准",
|
|
293
|
+
severity: "medium",
|
|
294
|
+
recommended_question: "转正考核指标、名额、周期和审批人分别是什么?"
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const red_lines = flags.filter(flag => flag.severity === "critical");
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
tool: "internship_compliance_checker",
|
|
302
|
+
version: "0.2.0",
|
|
303
|
+
compliance_flags: flags,
|
|
304
|
+
red_lines,
|
|
305
|
+
missing_items,
|
|
306
|
+
summary: {
|
|
307
|
+
red_line_count: red_lines.length,
|
|
308
|
+
high_risk_count: flags.filter(flag => flag.severity === "high").length,
|
|
309
|
+
missing_item_count: missing_items.length,
|
|
310
|
+
can_generate_report: red_lines.length === 0
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function calculateOfferScore(input) {
|
|
316
|
+
const jdText = normalizeText(input.jdText ?? input.jobProfile);
|
|
317
|
+
const studentText = normalizeText(input.studentProfile);
|
|
318
|
+
const riskResult = input.riskTermResult?.risk_terms ? input.riskTermResult : matchRiskTerms({ jdText });
|
|
319
|
+
const complianceResult = input.complianceResult?.summary ? input.complianceResult : checkInternshipCompliance({ jdText });
|
|
320
|
+
|
|
321
|
+
const riskTerms = riskResult.risk_terms ?? [];
|
|
322
|
+
const flags = complianceResult.compliance_flags ?? [];
|
|
323
|
+
const missingItems = complianceResult.missing_items ?? [];
|
|
324
|
+
const criticalCount = complianceResult.summary?.red_line_count ?? 0;
|
|
325
|
+
const highCount = flags.filter(flag => flag.severity === "high").length;
|
|
326
|
+
const mediumCount = [
|
|
327
|
+
...riskTerms.filter(item => item.severity === "medium"),
|
|
328
|
+
...missingItems.filter(item => item.severity === "medium")
|
|
329
|
+
].length;
|
|
330
|
+
|
|
331
|
+
const explicitSalary = salaryIsExplicit(jdText);
|
|
332
|
+
const mentor = hasMentor(jdText);
|
|
333
|
+
const conversionPath = hasConversionPath(jdText);
|
|
334
|
+
const projectValue = includesAny(jdText, ["上线", "核心项目", "真实项目", "可量化", "作品", "数据复盘"]);
|
|
335
|
+
const repetitive = includesAny(jdText, ["资料整理", "简单重复", "打杂", "行政杂务", "搬运"]);
|
|
336
|
+
const overtime = includesAny(jdText, ["996", "大小周", "随叫随到", "晚间在线", "周末响应", "加班"]);
|
|
337
|
+
const wantsStableHours = includesAny(studentText, ["不希望长期夜间", "不接受加班", "课业", "考研", "保研"]);
|
|
338
|
+
|
|
339
|
+
const dimensions = [
|
|
340
|
+
{
|
|
341
|
+
key: "role_match",
|
|
342
|
+
name: "岗位匹配",
|
|
343
|
+
score: clamp(68 + roleMatchBonus(jdText, studentText) - (repetitive ? 8 : 0)),
|
|
344
|
+
rationale: "根据岗位关键词与学生经历方向的重合度计算。"
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
key: "growth_value",
|
|
348
|
+
name: "成长价值",
|
|
349
|
+
score: clamp(65 + (mentor ? 12 : 0) + (projectValue ? 10 : 0) - (repetitive ? 18 : 0)),
|
|
350
|
+
rationale: "导师、真实项目和可沉淀产出会提升成长价值。"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
key: "salary_transparency",
|
|
354
|
+
name: "薪资透明",
|
|
355
|
+
score: clamp(58 + (explicitSalary ? 20 : -12) - (includesAny(jdText, ["薪资面议", "待遇面议"]) ? 15 : 0)),
|
|
356
|
+
rationale: "薪资金额、发放周期和绩效占比越明确,透明度越高。"
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
key: "work_intensity",
|
|
360
|
+
name: "工作强度",
|
|
361
|
+
score: clamp(78 - (overtime ? 25 : 0) - (wantsStableHours && overtime ? 15 : 0)),
|
|
362
|
+
rationale: "加班、随叫随到和学生时间偏好冲突会拉低该项。"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
key: "compliance_safety",
|
|
366
|
+
name: "合规安全",
|
|
367
|
+
score: clamp(86 - criticalCount * 55 - highCount * 22 - mediumCount * 6 - missingItems.length * 5),
|
|
368
|
+
rationale: "无薪试岗、押金、培训贷等红线优先级最高。"
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
key: "conversion_clarity",
|
|
372
|
+
name: "转正清晰",
|
|
373
|
+
score: clamp(62 + (conversionPath ? 20 : 0) - (includesAny(jdText, ["转正另议", "表现优秀可转正", "有转正机会"]) ? 15 : 0)),
|
|
374
|
+
rationale: "有量化转正标准和流程时分数更高。"
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
key: "resume_value",
|
|
378
|
+
name: "简历增益",
|
|
379
|
+
score: clamp(66 + (projectValue ? 15 : 0) + (mentor ? 5 : 0) - (repetitive ? 20 : 0)),
|
|
380
|
+
rationale: "能否形成项目、数据、作品或证明材料决定简历增益。"
|
|
381
|
+
}
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
const overall_score = clamp(dimensions.reduce((sum, item) => sum + item.score, 0) / dimensions.length);
|
|
385
|
+
let risk_level = "low";
|
|
386
|
+
let route = "advance";
|
|
387
|
+
let one_sentence_reason = "岗位信息较完整,暂未发现高风险红线,可继续推进并验证细节。";
|
|
388
|
+
|
|
389
|
+
if (criticalCount > 0) {
|
|
390
|
+
risk_level = "critical";
|
|
391
|
+
route = "reject";
|
|
392
|
+
one_sentence_reason = "命中实习合规红线,建议停止推进并保留沟通证据。";
|
|
393
|
+
} else if (highCount > 0 || riskTerms.some(item => item.severity === "high")) {
|
|
394
|
+
risk_level = "high";
|
|
395
|
+
route = "verify";
|
|
396
|
+
one_sentence_reason = "存在高风险信号,必须通过面试追问和书面材料验证后再决定。";
|
|
397
|
+
} else if (overall_score < 55) {
|
|
398
|
+
risk_level = "high";
|
|
399
|
+
route = "reject";
|
|
400
|
+
one_sentence_reason = "综合分较低且成长、合规或透明度不足,不建议优先选择。";
|
|
401
|
+
} else if (mediumCount >= 2 || missingItems.length >= 2 || overall_score < 73) {
|
|
402
|
+
risk_level = "medium";
|
|
403
|
+
route = explicitSalary ? "verify" : "negotiate";
|
|
404
|
+
one_sentence_reason = "信息仍有缺口或中风险较多,建议先验证薪资、工时、转正和协议。";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
tool: "offer_score_calculator",
|
|
409
|
+
version: "0.2.0",
|
|
410
|
+
overall_score,
|
|
411
|
+
risk_level,
|
|
412
|
+
route,
|
|
413
|
+
one_sentence_reason,
|
|
414
|
+
dimensions,
|
|
415
|
+
decision_rules_triggered: {
|
|
416
|
+
critical_compliance_first: criticalCount > 0,
|
|
417
|
+
high_risk_requires_verification: highCount > 0 || riskTerms.some(item => item.severity === "high"),
|
|
418
|
+
missing_information_requires_followup: missingItems.length >= 2
|
|
419
|
+
},
|
|
420
|
+
required_followup_questions: [
|
|
421
|
+
...missingItems.map(item => item.recommended_question),
|
|
422
|
+
...(overtime ? ["请问实习生平均每周工作时长、加班频率、调休和补贴规则分别是什么?"] : []),
|
|
423
|
+
...(projectValue ? [] : ["这个岗位三个月内可以沉淀哪些项目、数据或作品证明?"])
|
|
424
|
+
].slice(0, 6)
|
|
425
|
+
};
|
|
426
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
6
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
7
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import * as z from "zod/v4";
|
|
9
|
+
import {
|
|
10
|
+
calculateOfferScore,
|
|
11
|
+
checkInternshipCompliance,
|
|
12
|
+
matchRiskTerms
|
|
13
|
+
} from "./offerguard-tools.js";
|
|
14
|
+
|
|
15
|
+
const SERVICE_NAME = "OfferGuardMCP";
|
|
16
|
+
const SERVICE_VERSION = "0.2.0";
|
|
17
|
+
|
|
18
|
+
function jsonToolResult(result) {
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: "text",
|
|
23
|
+
text: JSON.stringify(result, null, 2)
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
structuredContent: result
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createOfferGuardMcpServer() {
|
|
31
|
+
const server = new McpServer({
|
|
32
|
+
name: SERVICE_NAME,
|
|
33
|
+
version: SERVICE_VERSION
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
server.registerTool(
|
|
37
|
+
"risk_term_matcher",
|
|
38
|
+
{
|
|
39
|
+
description: "Match internship JD risk terms and return severity, source evidence, and trigger reasons.",
|
|
40
|
+
inputSchema: {
|
|
41
|
+
jdText: z.string().min(1).describe("Raw internship JD or offer text.")
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async input => jsonToolResult(matchRiskTerms(input))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
server.registerTool(
|
|
48
|
+
"internship_compliance_checker",
|
|
49
|
+
{
|
|
50
|
+
description: "Check internship compliance red lines such as unpaid trials, deposits, training loans, work hours, salary payment, agreements, and IP ownership.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
jdText: z.string().min(1).describe("Raw internship JD text."),
|
|
53
|
+
offerText: z.string().optional().describe("Optional offer or HR message text."),
|
|
54
|
+
extraText: z.string().optional().describe("Optional extra context from the student.")
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
async input => jsonToolResult(checkInternshipCompliance(input))
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
server.registerTool(
|
|
61
|
+
"offer_score_calculator",
|
|
62
|
+
{
|
|
63
|
+
description: "Calculate OfferGuard seven-dimension scores, overall risk level, and recommendation route.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
jdText: z.string().min(1).describe("Raw internship JD text."),
|
|
66
|
+
studentProfile: z.union([z.string(), z.record(z.string(), z.any())]).describe("Student profile, resume summary, preferences, and risk tolerance."),
|
|
67
|
+
jobProfile: z.union([z.string(), z.record(z.string(), z.any())]).optional().describe("Optional parsed job profile."),
|
|
68
|
+
riskTermResult: z.record(z.string(), z.any()).optional().describe("Optional output from risk_term_matcher."),
|
|
69
|
+
complianceResult: z.record(z.string(), z.any()).optional().describe("Optional output from internship_compliance_checker.")
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async input => jsonToolResult(calculateOfferScore(input))
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return server;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createApp({ host = process.env.HOST ?? "127.0.0.1" } = {}) {
|
|
79
|
+
const app = createMcpExpressApp({ host });
|
|
80
|
+
const transports = {};
|
|
81
|
+
|
|
82
|
+
app.get("/health", (req, res) => {
|
|
83
|
+
res.json({
|
|
84
|
+
service: SERVICE_NAME,
|
|
85
|
+
version: SERVICE_VERSION,
|
|
86
|
+
status: "ok",
|
|
87
|
+
transports: ["streamable-http", "sse-legacy"],
|
|
88
|
+
tools: [
|
|
89
|
+
"risk_term_matcher",
|
|
90
|
+
"internship_compliance_checker",
|
|
91
|
+
"offer_score_calculator"
|
|
92
|
+
]
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
app.all("/mcp", async (req, res) => {
|
|
97
|
+
try {
|
|
98
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
99
|
+
let transport;
|
|
100
|
+
|
|
101
|
+
if (sessionId && transports[sessionId]) {
|
|
102
|
+
const existingTransport = transports[sessionId];
|
|
103
|
+
if (!(existingTransport instanceof StreamableHTTPServerTransport)) {
|
|
104
|
+
res.status(400).json({
|
|
105
|
+
jsonrpc: "2.0",
|
|
106
|
+
error: {
|
|
107
|
+
code: -32000,
|
|
108
|
+
message: "Bad Request: Session exists but uses a different transport protocol"
|
|
109
|
+
},
|
|
110
|
+
id: null
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
transport = existingTransport;
|
|
115
|
+
} else if (!sessionId && req.method === "POST" && isInitializeRequest(req.body)) {
|
|
116
|
+
transport = new StreamableHTTPServerTransport({
|
|
117
|
+
sessionIdGenerator: () => randomUUID(),
|
|
118
|
+
onsessioninitialized: initializedSessionId => {
|
|
119
|
+
transports[initializedSessionId] = transport;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
transport.onclose = () => {
|
|
124
|
+
const sid = transport.sessionId;
|
|
125
|
+
if (sid && transports[sid]) {
|
|
126
|
+
delete transports[sid];
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const server = createOfferGuardMcpServer();
|
|
131
|
+
await server.connect(transport);
|
|
132
|
+
} else {
|
|
133
|
+
res.status(400).json({
|
|
134
|
+
jsonrpc: "2.0",
|
|
135
|
+
error: {
|
|
136
|
+
code: -32000,
|
|
137
|
+
message: "Bad Request: No valid session ID provided"
|
|
138
|
+
},
|
|
139
|
+
id: null
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await transport.handleRequest(req, res, req.body);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error("Error handling /mcp request:", error);
|
|
147
|
+
if (!res.headersSent) {
|
|
148
|
+
res.status(500).json({
|
|
149
|
+
jsonrpc: "2.0",
|
|
150
|
+
error: {
|
|
151
|
+
code: -32603,
|
|
152
|
+
message: "Internal server error"
|
|
153
|
+
},
|
|
154
|
+
id: null
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
app.get("/sse", async (req, res) => {
|
|
161
|
+
const transport = new SSEServerTransport("/messages", res);
|
|
162
|
+
transports[transport.sessionId] = transport;
|
|
163
|
+
res.on("close", () => {
|
|
164
|
+
delete transports[transport.sessionId];
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const server = createOfferGuardMcpServer();
|
|
168
|
+
await server.connect(transport);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
app.post("/messages", async (req, res) => {
|
|
172
|
+
const sessionId = req.query.sessionId;
|
|
173
|
+
const existingTransport = transports[sessionId];
|
|
174
|
+
|
|
175
|
+
if (!(existingTransport instanceof SSEServerTransport)) {
|
|
176
|
+
res.status(400).json({
|
|
177
|
+
jsonrpc: "2.0",
|
|
178
|
+
error: {
|
|
179
|
+
code: -32000,
|
|
180
|
+
message: "Bad Request: No SSE transport found for sessionId"
|
|
181
|
+
},
|
|
182
|
+
id: null
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await existingTransport.handlePostMessage(req, res, req.body);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
app.closeTransports = async () => {
|
|
191
|
+
for (const sessionId of Object.keys(transports)) {
|
|
192
|
+
try {
|
|
193
|
+
await transports[sessionId].close();
|
|
194
|
+
} finally {
|
|
195
|
+
delete transports[sessionId];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return app;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function startServer({ port = Number(process.env.PORT ?? 3000), host = process.env.HOST ?? "127.0.0.1" } = {}) {
|
|
204
|
+
const app = createApp({ host });
|
|
205
|
+
const httpServer = app.listen(port, host, error => {
|
|
206
|
+
if (error) {
|
|
207
|
+
console.error("Failed to start OfferGuard MCP Server:", error);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
console.log(`${SERVICE_NAME} ${SERVICE_VERSION} listening on http://${host}:${port}`);
|
|
211
|
+
console.log("Streamable HTTP endpoint: /mcp");
|
|
212
|
+
console.log("Legacy SSE endpoints: /sse and /messages");
|
|
213
|
+
console.log("Health endpoint: /health");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const shutdown = async () => {
|
|
217
|
+
await app.closeTransports();
|
|
218
|
+
httpServer.close(() => process.exit(0));
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
process.once("SIGINT", shutdown);
|
|
222
|
+
process.once("SIGTERM", shutdown);
|
|
223
|
+
|
|
224
|
+
return { app, httpServer };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
228
|
+
startServer();
|
|
229
|
+
}
|
package/src/stdio.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { createOfferGuardMcpServer } from "./server.js";
|
|
5
|
+
|
|
6
|
+
const server = createOfferGuardMcpServer();
|
|
7
|
+
const transport = new StdioServerTransport();
|
|
8
|
+
|
|
9
|
+
await server.connect(transport);
|