accurlex-mcp-server 0.1.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/.env.example ADDED
@@ -0,0 +1,10 @@
1
+ ACCURLEX_PROXY_BASE_URL=https://accurlex.com
2
+ ACCURLEX_API_BASE_URL=https://accurlex.com/index.php
3
+
4
+ # Fill in after running accurlex_login tool, or manually from the website.
5
+ # JWT token is valid for 7 days.
6
+ ACCURLEX_BILLING_PHONE=
7
+ ACCURLEX_BEARER_TOKEN=
8
+
9
+ # Optional timeout override in milliseconds (default: 600000 = 10 min).
10
+ ACCURLEX_REQUEST_TIMEOUT_MS=600000
package/README.md ADDED
@@ -0,0 +1,332 @@
1
+ # accurLex MCP Server
2
+
3
+ accurLex 法律 AI 的 MCP 服务,支持 OpenClaw、VS Code GitHub Copilot、Claude Desktop 等 MCP 客户端。
4
+
5
+ ## 快速开始
6
+
7
+ ### 方式一:npx 直接运行(推荐)
8
+
9
+ 无需 clone 代码,无需手动安装依赖:
10
+
11
+ ```bash
12
+ npx accurlex-mcp-server
13
+ ```
14
+
15
+ ### 方式二:全局安装
16
+
17
+ ```bash
18
+ npm install -g accurlex-mcp-server
19
+ ```
20
+
21
+ ### 方式三:从源码运行
22
+
23
+ ```bash
24
+ git clone https://github.com/accurlex/accurlex-mcp-server.git
25
+ cd accurlex-mcp-server
26
+ npm install
27
+ cp .env.example .env
28
+ npm start
29
+ ```
30
+
31
+ ## 客户端配置
32
+
33
+ ### OpenClaw
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "accurlex": {
39
+ "command": "npx",
40
+ "args": ["accurlex-mcp-server"]
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### VS Code GitHub Copilot
47
+
48
+ 在工作区 `.vscode/settings.json` 中添加:
49
+
50
+ ```json
51
+ {
52
+ "mcp": {
53
+ "servers": {
54
+ "accurlex": {
55
+ "type": "stdio",
56
+ "command": "npx",
57
+ "args": ["accurlex-mcp-server"]
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### Claude Desktop
65
+
66
+ 编辑 `%APPDATA%\Claude\claude_desktop_config.json`(Windows)或 `~/Library/Application Support/Claude/claude_desktop_config.json`(macOS):
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "accurlex": {
72
+ "command": "npx",
73
+ "args": ["accurlex-mcp-server"]
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ ## 登录认证
80
+
81
+ ### 方式一:使用 `accurlex_login` 工具(推荐)
82
+
83
+ 连接 MCP 服务后,在 AI 客户端中说:
84
+
85
+ > "帮我登录 accurLex,手机号 138xxxx1234,密码 xxx"
86
+
87
+ 工具会返回 JWT token,按提示保存到 `.env` 文件,然后重启 AI 客户端即可。
88
+
89
+ ### 方式二:手动配置
90
+
91
+ 1. 在 https://accurlex.com 登录
92
+ 2. 打开浏览器开发者工具 → Application → localStorage → 复制 `al_jwt` 值
93
+ 3. 填入 `.env`:
94
+
95
+ ```env
96
+ ACCURLEX_BILLING_PHONE=你的手机号
97
+ ACCURLEX_BEARER_TOKEN=你的JWT令牌
98
+ ```
99
+
100
+ JWT 令牌有效期 **7 天**,过期后需重新登录获取。
101
+
102
+ ## 可用工具
103
+
104
+ | 工具 | 功能 | 计费 |
105
+ |------|------|------|
106
+ | `accurlex_login` | 手机号+密码登录,返回 JWT 令牌 | 免费 |
107
+ | `accurlex_legal_qa` | 中国法律问答(deep 免费 / expert 付费) | deep=免费, expert=扣点 |
108
+ | `accurlex_get_law_detail` | 查询法律条文 | 免费 |
109
+ | `accurlex_contract_review` | 合同审查 → 审查意见书(仅 normal 模式) | 扣点 |
110
+ | `accurlex_draft_document` | 法律文书生成(起诉状/答辩状/代理词等) | 扣点 |
111
+ | `accurlex_get_account_status` | 查询账户点数余额 | 免费 |
112
+ | `accurlex_extract_text_from_file` | 提取本地纯文本文件内容 | 免费 |
113
+
114
+ ## 输入限制(与网站对齐)
115
+
116
+ - 法律问答问题:**10,000 字**
117
+ - 合同审查:合同文本 + 立场合计 **10,000 字**
118
+ - 文书生成事实描述:**10,000 字**
119
+ - 审查人姓名:**40 字**
120
+
121
+ ## 环境变量
122
+
123
+ | 变量 | 必填 | 说明 |
124
+ |------|------|------|
125
+ | `ACCURLEX_PROXY_BASE_URL` | 是 | 流式 API 地址(默认 `https://accurlex.com`) |
126
+ | `ACCURLEX_API_BASE_URL` | 是 | PHP API 地址(默认 `https://accurlex.com/index.php`) |
127
+ | `ACCURLEX_BILLING_PHONE` | 付费工具需要 | 计费手机号 |
128
+ | `ACCURLEX_BEARER_TOKEN` | 账户查询需要 | 登录后获取的 JWT 令牌 |
129
+ | `ACCURLEX_REQUEST_TIMEOUT_MS` | 否 | 请求超时毫秒数(默认 600000 = 10 分钟) |
130
+
131
+ ## 当前限制
132
+
133
+ - 单账户模式(凭证来自 `.env`)
134
+ - 文件提取仅支持纯文本:`.txt`、`.md`、`.json`、`.csv`、`.text`
135
+ - 合同审查仅支持 normal 模式(审查意见书),不支持修订版
136
+ - 不支持 DOCX、PDF、图片 OCR(请使用网站处理这些格式)
137
+
138
+ ## 常见问题
139
+
140
+ ### PowerShell 编码问题
141
+
142
+ 通过 PowerShell 管道手动测试时,中文字符可能被破坏。这**不影响**正常使用(VS Code、OpenClaw、Claude Desktop 均正确处理 UTF-8)。
143
+
144
+ 如需命令行测试,先将 JSON 写入 UTF-8 文件:
145
+
146
+ ```powershell
147
+ [System.IO.File]::WriteAllText("test.jsonl", $jsonContent, [System.Text.Encoding]::UTF8)
148
+ Get-Content test.jsonl -Encoding UTF8 | node src/index.js
149
+ ```
150
+
151
+ ### "tool_unavailable" 错误
152
+
153
+ 缺少 `.env` 配置。确保 `ACCURLEX_PROXY_BASE_URL` 和 `ACCURLEX_API_BASE_URL` 已设置。付费工具还需要 `ACCURLEX_BILLING_PHONE`。
154
+
155
+ ### "insufficient_balance"(HTTP 402)
156
+
157
+ 账户点数不足,请到 https://accurlex.com 充值。
158
+
159
+ ### JWT 令牌过期
160
+
161
+ 重新调用 `accurlex_login` 获取新令牌,更新 `.env` 后重启即可。
162
+
163
+ ---
164
+
165
+ # accurLex MCP Server (English)
166
+
167
+ Node.js MCP server that exposes accurLex legal capabilities as MCP tools.
168
+ Designed for use with OpenClaw, VS Code GitHub Copilot, Claude Desktop, or any MCP-compatible AI client.
169
+
170
+ ## Quick Start
171
+
172
+ ### Option A: npx (recommended)
173
+
174
+ ```bash
175
+ npx accurlex-mcp-server
176
+ ```
177
+
178
+ ### Option B: Global install
179
+
180
+ ```bash
181
+ npm install -g accurlex-mcp-server
182
+ ```
183
+
184
+ ### Option C: From source
185
+
186
+ ```bash
187
+ git clone https://github.com/accurlex/accurlex-mcp-server.git
188
+ cd accurlex-mcp-server
189
+ npm install
190
+ cp .env.example .env
191
+ npm start
192
+ ```
193
+
194
+ ## Configuration
195
+
196
+ ### For OpenClaw
197
+
198
+ ```json
199
+ {
200
+ "mcpServers": {
201
+ "accurlex": {
202
+ "command": "npx",
203
+ "args": ["accurlex-mcp-server"]
204
+ }
205
+ }
206
+ }
207
+ ```
208
+
209
+ ### For VS Code GitHub Copilot
210
+
211
+ Add to your workspace `.vscode/settings.json`:
212
+
213
+ ```json
214
+ {
215
+ "mcp": {
216
+ "servers": {
217
+ "accurlex": {
218
+ "type": "stdio",
219
+ "command": "npx",
220
+ "args": ["accurlex-mcp-server"]
221
+ }
222
+ }
223
+ }
224
+ }
225
+ ```
226
+
227
+ ### For Claude Desktop
228
+
229
+ Edit `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
230
+
231
+ ```json
232
+ {
233
+ "mcpServers": {
234
+ "accurlex": {
235
+ "command": "npx",
236
+ "args": ["accurlex-mcp-server"]
237
+ }
238
+ }
239
+ }
240
+ ```
241
+
242
+ ## Authentication
243
+
244
+ Two ways to authenticate:
245
+
246
+ ### Option A: Use the `accurlex_login` tool (recommended)
247
+
248
+ After connecting the MCP server, ask the AI client:
249
+
250
+ > "帮我登录 accurLex,手机号 138xxxx1234,密码 xxx"
251
+
252
+ The tool will return a JWT token and instructions to save it in `.env`. Then restart the MCP server (reload the AI client window).
253
+
254
+ ### Option B: Manual configuration
255
+
256
+ 1. Login at https://accurlex.com
257
+ 2. Open browser DevTools → Application → localStorage → copy `al_jwt` value
258
+ 3. Fill in `.env`:
259
+
260
+ ```env
261
+ ACCURLEX_BILLING_PHONE=your_phone_number
262
+ ACCURLEX_BEARER_TOKEN=your_jwt_token
263
+ ```
264
+
265
+ JWT tokens are valid for **7 days**. After expiry, re-login to get a new token.
266
+
267
+ ## Implemented Tools
268
+
269
+ | Tool | Description | Billing |
270
+ |------|-------------|---------|
271
+ | `accurlex_login` | Login with phone + password, returns JWT | free |
272
+ | `accurlex_legal_qa` | Chinese-law legal Q&A (deep free / expert paid) | deep=free, expert=paid |
273
+ | `accurlex_get_law_detail` | Retrieve law text or article details | free |
274
+ | `accurlex_contract_review` | Contract review → 审查意见书 (normal mode only) | paid |
275
+ | `accurlex_draft_document` | Draft legal documents (起诉状/答辩状/代理词 etc.) | paid |
276
+ | `accurlex_get_account_status` | Query account and point balances | free |
277
+ | `accurlex_extract_text_from_file` | Extract text from local plaintext files | free |
278
+
279
+ ## Input Limits (aligned with website)
280
+
281
+ - Legal QA question: **10,000 chars** max
282
+ - Contract review: contract text + standpoint combined **10,000 chars** max
283
+ - Draft document facts: **10,000 chars** max
284
+ - Reviewer name: **40 chars** max
285
+
286
+ ## Environment Variables
287
+
288
+ | Variable | Required | Description |
289
+ |----------|----------|-------------|
290
+ | `ACCURLEX_PROXY_BASE_URL` | Yes | Streaming API base URL (default: `https://accurlex.com`) |
291
+ | `ACCURLEX_API_BASE_URL` | Yes | PHP API base URL (default: `https://accurlex.com/index.php`) |
292
+ | `ACCURLEX_BILLING_PHONE` | For paid tools | Phone number for billing identity |
293
+ | `ACCURLEX_BEARER_TOKEN` | For account status | JWT token from login |
294
+ | `ACCURLEX_REQUEST_TIMEOUT_MS` | No | Request timeout in ms (default: 600000) |
295
+
296
+ ## Current Limitations
297
+
298
+ - Single-account per server instance (credentials from `.env`)
299
+ - Local file extraction supports plaintext only: `.txt`, `.md`, `.json`, `.csv`, `.text`
300
+ - Contract review only supports normal mode (审查意见书), revision mode is not available
301
+ - DOCX, PDF, image OCR are not supported (use the website for these)
302
+
303
+ ## Troubleshooting
304
+
305
+ ### PowerShell encoding issues
306
+
307
+ When testing via PowerShell stdin pipe, Chinese characters may get corrupted. This only affects manual CLI testing — it does **not** affect normal MCP client usage (VS Code, OpenClaw, Claude Desktop all handle UTF-8 correctly).
308
+
309
+ If you must test via CLI, write the JSON-RPC input to a UTF-8 file first:
310
+
311
+ ```powershell
312
+ [System.IO.File]::WriteAllText("test.jsonl", $jsonContent, [System.Text.Encoding]::UTF8)
313
+ Get-Content test.jsonl -Encoding UTF8 | node src/index.js
314
+ ```
315
+
316
+ ### "tool_unavailable" errors
317
+
318
+ Missing `.env` configuration. Ensure `ACCURLEX_PROXY_BASE_URL` and `ACCURLEX_API_BASE_URL` are set. For paid tools, also set `ACCURLEX_BILLING_PHONE`.
319
+
320
+ ### "insufficient_balance" (HTTP 402)
321
+
322
+ Your account has insufficient points. Top up at https://accurlex.com.
323
+
324
+ ### JWT token expired
325
+
326
+ Re-run `accurlex_login` to get a new token, then update `.env` and restart.
327
+
328
+ ## Suggested Next Steps
329
+
330
+ 1. Add OAuth or SMS-based login tool for seamless credential setup
331
+ 2. Add file upload handling for DOCX, PDF, and OCR workflows
332
+ 3. Replace env-bound billing identity with per-user auth for multi-user support
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "accurlex-mcp-server",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "MCP server for accurLex Chinese legal AI — legal Q&A, contract review, law lookup, document drafting",
6
+ "keywords": ["mcp", "legal", "chinese-law", "contract-review", "accurlex", "ai"],
7
+ "license": "MIT",
8
+ "author": "accurLex",
9
+ "homepage": "https://accurlex.com",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/accurlex/accurlex-mcp-server"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "bin": {
18
+ "accurlex-mcp-server": "src/index.js"
19
+ },
20
+ "files": [
21
+ "src/",
22
+ "README.md",
23
+ ".env.example"
24
+ ],
25
+ "scripts": {
26
+ "start": "node src/index.js"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.9.0",
30
+ "dotenv": "^17.3.1"
31
+ }
32
+ }
package/src/index.js ADDED
@@ -0,0 +1,756 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+
6
+ import dotenv from 'dotenv';
7
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import {
10
+ CallToolRequestSchema,
11
+ ListToolsRequestSchema,
12
+ } from '@modelcontextprotocol/sdk/types.js';
13
+
14
+ dotenv.config();
15
+
16
+ const REQUEST_TIMEOUT_MS = Number(process.env.ACCURLEX_REQUEST_TIMEOUT_MS || 600000);
17
+ const PROXY_BASE_URL = (process.env.ACCURLEX_PROXY_BASE_URL || '').trim();
18
+ const API_BASE_URL = (process.env.ACCURLEX_API_BASE_URL || '').trim();
19
+ const BILLING_PHONE = (process.env.ACCURLEX_BILLING_PHONE || '').trim();
20
+ const BEARER_TOKEN = (process.env.ACCURLEX_BEARER_TOKEN || '').trim();
21
+
22
+ const TEXT_FILE_EXTENSIONS = new Set(['.txt', '.text', '.md', '.json', '.csv']);
23
+ const MAX_INPUT_CHARS = 10000;
24
+
25
+ const server = new Server(
26
+ {
27
+ name: 'accurlex-mcp-server',
28
+ version: '0.1.0',
29
+ },
30
+ {
31
+ capabilities: {
32
+ tools: {},
33
+ },
34
+ },
35
+ );
36
+
37
+ const tools = [
38
+ {
39
+ name: 'accurlex_legal_qa',
40
+ description: 'Answer a Chinese-law legal question using accurLex deep or expert mode.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ question: { type: 'string', maxLength: 10000, description: 'The legal question to analyze (max 10000 chars).' },
45
+ mode: {
46
+ type: 'string',
47
+ enum: ['deep', 'expert'],
48
+ default: 'deep',
49
+ description: 'deep is free/basic mode. expert is paid mode.',
50
+ },
51
+ history: {
52
+ type: 'array',
53
+ items: {
54
+ type: 'object',
55
+ properties: {
56
+ role: { type: 'string', enum: ['user', 'assistant'] },
57
+ content: { type: 'string' },
58
+ },
59
+ required: ['role', 'content'],
60
+ },
61
+ },
62
+ context_text: { type: 'string', maxLength: 10000, description: 'Optional background facts or extracted file text.' },
63
+ },
64
+ required: ['question'],
65
+ },
66
+ },
67
+ {
68
+ name: 'accurlex_get_law_detail',
69
+ description: 'Retrieve full law text or article details for a named Chinese law or regulation.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ law_name: { type: 'string' },
74
+ article: { type: 'string' },
75
+ keyword: { type: 'string' },
76
+ },
77
+ required: ['law_name'],
78
+ },
79
+ },
80
+ {
81
+ name: 'accurlex_contract_review',
82
+ description: 'Review contract text and generate a written review opinion (审查意见书) from a specified standpoint.',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ contract_text: {
87
+ type: 'string',
88
+ maxLength: 10000,
89
+ description: 'The contract text to review. Combined with standpoint must not exceed 10000 chars.',
90
+ },
91
+ file_ref: { type: 'string', description: 'Local plaintext file path as alternative to contract_text.' },
92
+ standpoint: {
93
+ type: 'string',
94
+ maxLength: 10000,
95
+ description: 'Your review standpoint and requirements, e.g. "我是甲方(买方),请重点审查付款条款和违约责任".',
96
+ },
97
+ reviewer_name: { type: 'string', maxLength: 40, description: 'Reviewer name for the opinion header, e.g. "张律师".' },
98
+ },
99
+ required: ['standpoint'],
100
+ },
101
+ },
102
+ {
103
+ name: 'accurlex_draft_document',
104
+ description: 'Draft a legal document based on facts and optional reference text.',
105
+ inputSchema: {
106
+ type: 'object',
107
+ properties: {
108
+ document_type: { type: 'string', description: 'Document type, e.g. "民事起诉状", "答辩状", "代理词".' },
109
+ facts: { type: 'string', maxLength: 10000, description: 'Key facts and claims (max 10000 chars).' },
110
+ case_file_refs: {
111
+ type: 'array',
112
+ items: { type: 'string' },
113
+ description: 'Optional local plaintext file paths as reference material.',
114
+ },
115
+ style_file_ref: { type: 'string', description: 'Optional local plaintext file path as style sample.' },
116
+ followup_prompt: { type: 'string', maxLength: 2000, description: 'Optional additional instructions.' },
117
+ },
118
+ required: ['document_type', 'facts'],
119
+ },
120
+ },
121
+ {
122
+ name: 'accurlex_get_account_status',
123
+ description: 'Return the configured accurLex account status and point balances.',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {},
127
+ },
128
+ },
129
+ {
130
+ name: 'accurlex_login',
131
+ description: 'Login to accurLex with phone number and password. Returns JWT token and account info. The token is valid for 7 days.',
132
+ inputSchema: {
133
+ type: 'object',
134
+ properties: {
135
+ phone_num: { type: 'string', description: 'Registered phone number.' },
136
+ password: { type: 'string', description: 'Account password.' },
137
+ },
138
+ required: ['phone_num', 'password'],
139
+ },
140
+ },
141
+ {
142
+ name: 'accurlex_extract_text_from_file',
143
+ description: 'Extract text from a local plaintext file path in the MVP server.',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {
147
+ file_ref: { type: 'string' },
148
+ },
149
+ required: ['file_ref'],
150
+ },
151
+ },
152
+ ];
153
+
154
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
155
+
156
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
157
+ const name = request.params.name;
158
+ const args = request.params.arguments || {};
159
+
160
+ try {
161
+ switch (name) {
162
+ case 'accurlex_legal_qa':
163
+ return successResult(await handleLegalQa(args));
164
+ case 'accurlex_get_law_detail':
165
+ return successResult(await handleGetLawDetail(args));
166
+ case 'accurlex_contract_review':
167
+ return successResult(await handleContractReview(args));
168
+ case 'accurlex_draft_document':
169
+ return successResult(await handleDraftDocument(args));
170
+ case 'accurlex_get_account_status':
171
+ return successResult(await handleGetAccountStatus());
172
+ case 'accurlex_login':
173
+ return successResult(await handleLogin(args));
174
+ case 'accurlex_extract_text_from_file':
175
+ return successResult(await handleExtractTextFromFile(args));
176
+ default:
177
+ return errorResult(`Unknown tool: ${name}`);
178
+ }
179
+ } catch (error) {
180
+ return errorResult(formatError(error), error?.code);
181
+ }
182
+ });
183
+
184
+ async function handleLegalQa(args) {
185
+ assertConfigured(PROXY_BASE_URL, 'ACCURLEX_PROXY_BASE_URL is required');
186
+
187
+ const question = asNonEmptyString(args.question, 'question');
188
+ if (question.length > MAX_INPUT_CHARS) {
189
+ throw withCode(new Error(`问题字数超出上限(${question.length}/${MAX_INPUT_CHARS})`), 'input_too_long');
190
+ }
191
+ const mode = args.mode === 'expert' ? 'expert' : 'deep';
192
+ const history = Array.isArray(args.history) ? args.history : [];
193
+ const contextText = optionalString(args.context_text);
194
+ const route = mode === 'expert' ? '/ask_stream' : '/ask_free_stream';
195
+ const payload = {
196
+ prompt: contextText ? `${contextText.trim()}\n\n${question}` : question,
197
+ func_select: 'query_law',
198
+ history: buildHistoryString(history),
199
+ stream: true,
200
+ };
201
+
202
+ const headers = { 'Content-Type': 'application/json' };
203
+ if (mode === 'expert') {
204
+ assertConfigured(BILLING_PHONE, 'ACCURLEX_BILLING_PHONE is required for expert mode');
205
+ headers['X-Billing-Phone'] = BILLING_PHONE;
206
+ }
207
+
208
+ const rawText = await postJsonToRoute(route, payload, headers);
209
+ const events = extractAccurlexEvents(rawText);
210
+ const answer = collectEventText(events);
211
+ const citations = collectCitations(events);
212
+ const warnings = collectWarnings(events);
213
+
214
+ return {
215
+ answer,
216
+ mode_used: mode,
217
+ citations,
218
+ warnings,
219
+ raw_event_count: events.length,
220
+ };
221
+ }
222
+
223
+ async function handleGetLawDetail(args) {
224
+ assertConfigured(API_BASE_URL, 'ACCURLEX_API_BASE_URL is required');
225
+
226
+ const lawName = asNonEmptyString(args.law_name, 'law_name');
227
+ const response = await postFormToPhp('/Main/getLawDetail', {
228
+ law_name: lawName,
229
+ });
230
+
231
+ if (!response || response.status === 'NotFound') {
232
+ return {
233
+ law_name: lawName,
234
+ content: '',
235
+ matched_articles: [],
236
+ status: 'NotFound',
237
+ };
238
+ }
239
+
240
+ const articles = Array.isArray(response.articles) ? response.articles : [];
241
+ const requestedArticle = optionalString(args.article);
242
+ const keyword = optionalString(args.keyword);
243
+ const filteredArticles = articles.filter((article) => filterArticle(article, requestedArticle, keyword));
244
+ const matchedArticles = filteredArticles.map(mapLawArticle);
245
+ const content = matchedArticles
246
+ .map((item) => `${item.article}\n${item.text}`.trim())
247
+ .join('\n\n');
248
+
249
+ return {
250
+ law_name: response.law?.law_name || lawName,
251
+ content,
252
+ matched_articles: matchedArticles,
253
+ status: response.status || 'Suc',
254
+ };
255
+ }
256
+
257
+ async function handleContractReview(args) {
258
+ assertConfigured(PROXY_BASE_URL, 'ACCURLEX_PROXY_BASE_URL is required');
259
+ assertConfigured(BILLING_PHONE, 'ACCURLEX_BILLING_PHONE is required for contract review');
260
+
261
+ const standpoint = asNonEmptyString(args.standpoint, 'standpoint');
262
+ const contractText = await resolveTextInput(args.contract_text, args.file_ref, 'contract_text');
263
+
264
+ // Enforce combined character limit (aligned with website: 10000 chars total)
265
+ const totalLen = contractText.length + standpoint.length;
266
+ if (totalLen > MAX_INPUT_CHARS) {
267
+ throw withCode(
268
+ new Error(`合同文本+审查立场总字数超出上限(${totalLen}/${MAX_INPUT_CHARS}),请精简输入`),
269
+ 'input_too_long',
270
+ );
271
+ }
272
+
273
+ const rawText = await postJsonToRoute(
274
+ '/contract_review_stream',
275
+ {
276
+ user_input: contractText,
277
+ user_standpoint: standpoint,
278
+ func_select: 'contract_review',
279
+ output_mode: 'normal',
280
+ history: '',
281
+ stream: true,
282
+ },
283
+ {
284
+ 'Content-Type': 'application/json',
285
+ 'X-Billing-Phone': BILLING_PHONE,
286
+ },
287
+ );
288
+
289
+ const events = extractAccurlexEvents(rawText);
290
+ return {
291
+ review_text: collectEventText(events),
292
+ citations: collectCitations(events),
293
+ warnings: collectWarnings(events),
294
+ reviewer_name: optionalString(args.reviewer_name) || '',
295
+ raw_event_count: events.length,
296
+ };
297
+ }
298
+
299
+ async function handleDraftDocument(args) {
300
+ assertConfigured(PROXY_BASE_URL, 'ACCURLEX_PROXY_BASE_URL is required');
301
+ assertConfigured(BILLING_PHONE, 'ACCURLEX_BILLING_PHONE is required for drafting');
302
+
303
+ const documentType = asNonEmptyString(args.document_type, 'document_type');
304
+ const facts = asNonEmptyString(args.facts, 'facts');
305
+ if (facts.length > MAX_INPUT_CHARS) {
306
+ throw withCode(new Error(`事实描述字数超出上限(${facts.length}/${MAX_INPUT_CHARS})`), 'input_too_long');
307
+ }
308
+ const referenceTexts = await resolveManyTextFiles(args.case_file_refs);
309
+ const styleText = optionalString(args.style_file_ref)
310
+ ? await readLocalTextFile(String(args.style_file_ref))
311
+ : '';
312
+ const followupPrompt = optionalString(args.followup_prompt);
313
+
314
+ const prompt = [
315
+ `文书类型:${documentType}`,
316
+ `事实与诉求:${facts}`,
317
+ followupPrompt ? `补充要求:${followupPrompt}` : '',
318
+ ].filter(Boolean).join('\n');
319
+
320
+ const rawText = await postJsonToRoute(
321
+ '/draft_stream',
322
+ {
323
+ prompt,
324
+ reference_material: referenceTexts.join('\n\n'),
325
+ sample_document: styleText,
326
+ history: '',
327
+ stream: true,
328
+ },
329
+ {
330
+ 'Content-Type': 'application/json',
331
+ 'X-Billing-Phone': BILLING_PHONE,
332
+ },
333
+ );
334
+
335
+ const events = extractAccurlexEvents(rawText);
336
+ return {
337
+ document_text: collectEventText(events),
338
+ document_type_used: documentType,
339
+ warnings: collectWarnings(events),
340
+ raw_event_count: events.length,
341
+ };
342
+ }
343
+
344
+ async function handleGetAccountStatus() {
345
+ assertConfigured(API_BASE_URL, 'ACCURLEX_API_BASE_URL is required');
346
+ assertConfigured(BEARER_TOKEN, 'ACCURLEX_BEARER_TOKEN is required for account status');
347
+
348
+ const response = await postFormToPhp(
349
+ '/Main/SynUserInfo',
350
+ { platform: 'mcp' },
351
+ {
352
+ Authorization: `Bearer ${BEARER_TOKEN}`,
353
+ },
354
+ );
355
+
356
+ const user = response?.uinfo || {};
357
+ return {
358
+ user_id: String(user.phone_num || user.phone || ''),
359
+ phone_masked: maskPhone(String(user.phone_num || user.phone || BILLING_PHONE || '')),
360
+ res_point: toInt(user.res_point),
361
+ vip_point: toInt(user.vip_point),
362
+ capabilities: {
363
+ deep_qa: true,
364
+ expert_qa: Boolean(BILLING_PHONE),
365
+ contract_review: Boolean(BILLING_PHONE),
366
+ draft_document: Boolean(BILLING_PHONE),
367
+ },
368
+ raw_profile: user,
369
+ };
370
+ }
371
+
372
+ async function handleLogin(args) {
373
+ assertConfigured(API_BASE_URL, 'ACCURLEX_API_BASE_URL is required');
374
+
375
+ const phoneNum = asNonEmptyString(args.phone_num, 'phone_num');
376
+ const password = asNonEmptyString(args.password, 'password');
377
+
378
+ const response = await postFormToPhp('/Main/Login', {
379
+ phone_num: phoneNum,
380
+ pwd: password,
381
+ platform: 'mcp',
382
+ });
383
+
384
+ if (!response || typeof response === 'string') {
385
+ const code = response === 'pwd_err' ? 'auth_failed'
386
+ : response === 'too_many_requests' ? 'rate_limited'
387
+ : response === 'account_deleted' ? 'account_deleted'
388
+ : 'login_failed';
389
+ const msg = response === 'pwd_err' ? '\u624b\u673a\u53f7\u6216\u5bc6\u7801\u9519\u8bef'
390
+ : response === 'too_many_requests' ? '\u767b\u5f55\u5c1d\u8bd5\u6b21\u6570\u8fc7\u591a\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5'
391
+ : response === 'account_deleted' ? '\u8d26\u6237\u5df2\u88ab\u5220\u9664'
392
+ : '\u767b\u5f55\u5931\u8d25';
393
+ throw withCode(new Error(msg), code);
394
+ }
395
+
396
+ const token = response.token || '';
397
+ const user = response.uinfo || {};
398
+
399
+ return {
400
+ success: true,
401
+ token,
402
+ phone_masked: maskPhone(phoneNum),
403
+ res_point: toInt(user.res_point),
404
+ vip_point: toInt(user.vip_point),
405
+ message: `\u767b\u5f55\u6210\u529f\u3002\u8bf7\u5c06\u4ee5\u4e0b\u914d\u7f6e\u586b\u5165 .env \u6587\u4ef6:\nACCURLEX_BEARER_TOKEN=${token}\nACCURLEX_BILLING_PHONE=${phoneNum}`,
406
+ };
407
+ }
408
+
409
+ async function handleExtractTextFromFile(args) {
410
+ const fileRef = asNonEmptyString(args.file_ref, 'file_ref');
411
+ const text = await readLocalTextFile(fileRef);
412
+ return {
413
+ text,
414
+ source_type: path.extname(fileRef).toLowerCase().replace('.', '') || 'text',
415
+ truncated: false,
416
+ };
417
+ }
418
+
419
+ async function postJsonToRoute(route, payload, headers = {}) {
420
+ const url = joinUrl(PROXY_BASE_URL, route);
421
+ const response = await fetchWithTimeout(url, {
422
+ method: 'POST',
423
+ headers,
424
+ body: JSON.stringify(payload),
425
+ });
426
+ return await readResponseText(response);
427
+ }
428
+
429
+ async function postFormToPhp(endpointPath, fields, headers = {}) {
430
+ const url = joinUrl(API_BASE_URL, endpointPath);
431
+ const formData = new FormData();
432
+
433
+ Object.entries(fields || {}).forEach(([key, value]) => {
434
+ if (value === undefined || value === null || value === '') return;
435
+ formData.append(key, String(value));
436
+ });
437
+
438
+ const response = await fetchWithTimeout(url, {
439
+ method: 'POST',
440
+ headers,
441
+ body: formData,
442
+ });
443
+
444
+ const text = await response.text();
445
+ if (!response.ok) {
446
+ throw withCode(new Error(`HTTP ${response.status}: ${text || 'request failed'}`), 'upstream_http_error');
447
+ }
448
+
449
+ try {
450
+ return JSON.parse(text);
451
+ } catch {
452
+ return text;
453
+ }
454
+ }
455
+
456
+ async function fetchWithTimeout(url, options) {
457
+ const controller = new AbortController();
458
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
459
+
460
+ try {
461
+ const response = await fetch(url, {
462
+ ...options,
463
+ signal: controller.signal,
464
+ });
465
+ return response;
466
+ } catch (error) {
467
+ if (error?.name === 'AbortError') {
468
+ throw withCode(new Error(`Request timed out for ${url}`), 'upstream_timeout');
469
+ }
470
+ throw error;
471
+ } finally {
472
+ clearTimeout(timeoutId);
473
+ }
474
+ }
475
+
476
+ async function readResponseText(response) {
477
+ const text = await response.text();
478
+ if (!response.ok) {
479
+ const code = response.status === 402 ? 'insufficient_balance' : response.status === 429 ? 'rate_limited' : 'upstream_http_error';
480
+ throw withCode(new Error(`HTTP ${response.status}: ${text || 'request failed'}`), code);
481
+ }
482
+ return text;
483
+ }
484
+
485
+ function extractAccurlexEvents(rawText) {
486
+ const events = [];
487
+ const lines = String(rawText || '').split(/\r?\n/);
488
+ const fallbackParts = [];
489
+
490
+ for (const line of lines) {
491
+ const trimmed = line.trim();
492
+ if (!trimmed) continue;
493
+ if (trimmed.startsWith('data:')) {
494
+ const payload = trimmed.slice(5).trim();
495
+ if (!payload || payload === '[DONE]') continue;
496
+ events.push(parseLooseJson(payload));
497
+ } else {
498
+ fallbackParts.push(line);
499
+ }
500
+ }
501
+
502
+ const fallbackText = fallbackParts.join('\n').trim();
503
+ if (fallbackText) {
504
+ const parsedObjects = parseConcatenatedJsonObjects(fallbackText);
505
+ if (parsedObjects.length > 0) {
506
+ events.push(...parsedObjects);
507
+ } else {
508
+ events.push(parseLooseJson(fallbackText));
509
+ }
510
+ }
511
+
512
+ return events;
513
+ }
514
+
515
+ function parseLooseJson(text) {
516
+ try {
517
+ return JSON.parse(text);
518
+ } catch {
519
+ return { data: text };
520
+ }
521
+ }
522
+
523
+ function parseConcatenatedJsonObjects(text) {
524
+ const objects = [];
525
+ let bracketCount = 0;
526
+ let start = -1;
527
+ let inString = false;
528
+ let escaped = false;
529
+
530
+ for (let i = 0; i < text.length; i += 1) {
531
+ const ch = text[i];
532
+ if (escaped) {
533
+ escaped = false;
534
+ continue;
535
+ }
536
+ if (ch === '\\') {
537
+ escaped = true;
538
+ continue;
539
+ }
540
+ if (ch === '"') {
541
+ inString = !inString;
542
+ continue;
543
+ }
544
+ if (inString) continue;
545
+
546
+ if (ch === '{') {
547
+ if (bracketCount === 0) start = i;
548
+ bracketCount += 1;
549
+ } else if (ch === '}') {
550
+ bracketCount -= 1;
551
+ if (bracketCount === 0 && start >= 0) {
552
+ const jsonText = text.slice(start, i + 1);
553
+ try {
554
+ objects.push(JSON.parse(jsonText));
555
+ } catch {
556
+ return [];
557
+ }
558
+ start = -1;
559
+ }
560
+ }
561
+ }
562
+
563
+ return objects;
564
+ }
565
+
566
+ function collectEventText(events) {
567
+ return events
568
+ .map((event) => {
569
+ if (event?.data === undefined || event?.data === null) return '';
570
+ return String(event.data);
571
+ })
572
+ .join('')
573
+ .trim();
574
+ }
575
+
576
+ function collectWarnings(events) {
577
+ const warnings = [];
578
+ for (const event of events) {
579
+ if (event?.error) warnings.push(String(event.error));
580
+ if (event?.msg && String(event.msg).toLowerCase() !== 'suc') warnings.push(String(event.msg));
581
+ }
582
+ return Array.from(new Set(warnings));
583
+ }
584
+
585
+ function collectCitations(events) {
586
+ const citations = [];
587
+
588
+ for (const event of events) {
589
+ if (Array.isArray(event?.citations)) {
590
+ for (const citation of event.citations) {
591
+ citations.push(normalizeCitation(citation));
592
+ }
593
+ continue;
594
+ }
595
+
596
+ if (event?.law_name || event?.article) {
597
+ citations.push(normalizeCitation(event));
598
+ }
599
+ }
600
+
601
+ return citations.filter((item) => item.law_name || item.article || item.snippet);
602
+ }
603
+
604
+ function normalizeCitation(value) {
605
+ return {
606
+ law_name: optionalString(value?.law_name) || '',
607
+ article: optionalString(value?.article) || optionalString(value?.article_num) || '',
608
+ snippet: optionalString(value?.snippet) || optionalString(value?.original_content) || '',
609
+ };
610
+ }
611
+
612
+ function buildHistoryString(history) {
613
+ return history
614
+ .map((item) => `${item.role === 'assistant' ? 'Assistant' : 'User'}: ${String(item.content || '')}`)
615
+ .join('\n');
616
+ }
617
+
618
+ function filterArticle(article, requestedArticle, keyword) {
619
+ const articleLabel = String(article?.article_num || article?.article_no || article?.title || '').trim();
620
+ const text = String(article?.content || article?.article_content || article?.text || '').trim();
621
+
622
+ if (requestedArticle && !articleLabel.includes(requestedArticle)) {
623
+ return false;
624
+ }
625
+ if (keyword && !(`${articleLabel}\n${text}`).includes(keyword)) {
626
+ return false;
627
+ }
628
+ return true;
629
+ }
630
+
631
+ function mapLawArticle(article) {
632
+ return {
633
+ article: String(article?.article_num || article?.article_no || article?.title || ''),
634
+ text: String(article?.content || article?.article_content || article?.text || ''),
635
+ };
636
+ }
637
+
638
+ async function resolveTextInput(textValue, fileRef, fieldName) {
639
+ const text = optionalString(textValue);
640
+ if (text) return text;
641
+ if (optionalString(fileRef)) return readLocalTextFile(String(fileRef));
642
+ throw withCode(new Error(`${fieldName} or file_ref is required`), 'invalid_input');
643
+ }
644
+
645
+ async function resolveManyTextFiles(fileRefs) {
646
+ if (!Array.isArray(fileRefs) || fileRefs.length === 0) return [];
647
+ const texts = [];
648
+ for (const fileRef of fileRefs) {
649
+ if (!fileRef) continue;
650
+ texts.push(await readLocalTextFile(String(fileRef)));
651
+ }
652
+ return texts;
653
+ }
654
+
655
+ async function readLocalTextFile(fileRef) {
656
+ const resolvedPath = path.resolve(String(fileRef));
657
+ const ext = path.extname(resolvedPath).toLowerCase();
658
+ if (!TEXT_FILE_EXTENSIONS.has(ext)) {
659
+ throw withCode(
660
+ new Error(`Unsupported file type for MVP extraction: ${ext || '(no extension)'}`),
661
+ 'unsupported_file_type',
662
+ );
663
+ }
664
+
665
+ try {
666
+ return await fs.readFile(resolvedPath, 'utf8');
667
+ } catch (error) {
668
+ throw withCode(new Error(`Failed to read file: ${resolvedPath} (${error.message})`), 'file_read_error');
669
+ }
670
+ }
671
+
672
+ function joinUrl(base, pathname) {
673
+ const normalizedBase = String(base || '').replace(/\/+$/, '');
674
+ const normalizedPath = String(pathname || '').replace(/^\/+/, '');
675
+ return `${normalizedBase}/${normalizedPath}`;
676
+ }
677
+
678
+ function asNonEmptyString(value, fieldName) {
679
+ const text = sanitizeText(optionalString(value));
680
+ if (!text) {
681
+ throw withCode(new Error(`${fieldName} is required`), 'invalid_input');
682
+ }
683
+ return text;
684
+ }
685
+
686
+ function optionalString(value) {
687
+ return typeof value === 'string' ? value.trim() : '';
688
+ }
689
+
690
+ function sanitizeText(text) {
691
+ return text
692
+ .replace(/\u0000/g, '')
693
+ .replace(/[\u0001-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
694
+ }
695
+
696
+ function assertConfigured(value, message) {
697
+ if (!value) {
698
+ throw withCode(new Error(message), 'tool_unavailable');
699
+ }
700
+ }
701
+
702
+ function toInt(value) {
703
+ const parsed = Number.parseInt(String(value ?? ''), 10);
704
+ return Number.isNaN(parsed) ? 0 : parsed;
705
+ }
706
+
707
+ function maskPhone(phone) {
708
+ if (!phone || phone.length < 7) return phone;
709
+ return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
710
+ }
711
+
712
+ function withCode(error, code) {
713
+ error.code = code;
714
+ return error;
715
+ }
716
+
717
+ function formatError(error) {
718
+ if (!error) return 'Unknown error';
719
+ if (typeof error === 'string') return error;
720
+ return error.message || String(error);
721
+ }
722
+
723
+ function successResult(result) {
724
+ return {
725
+ content: [
726
+ {
727
+ type: 'text',
728
+ text: JSON.stringify(result, null, 2),
729
+ },
730
+ ],
731
+ structuredContent: result,
732
+ };
733
+ }
734
+
735
+ function errorResult(message, code = 'tool_error') {
736
+ return {
737
+ content: [
738
+ {
739
+ type: 'text',
740
+ text: JSON.stringify({ code, message }, null, 2),
741
+ },
742
+ ],
743
+ isError: true,
744
+ };
745
+ }
746
+
747
+ async function main() {
748
+ const transport = new StdioServerTransport();
749
+ await server.connect(transport);
750
+ console.error('accurLex MCP server running on stdio');
751
+ }
752
+
753
+ main().catch((error) => {
754
+ console.error('Failed to start accurLex MCP server:', error);
755
+ process.exit(1);
756
+ });