@camscanner/mcp-language-server 1.0.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/.claude-plugin/marketplace.json +20 -0
- package/README.md +48 -0
- package/dist/auth.d.ts +12 -0
- package/dist/auth.js +141 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +299 -0
- package/dist/operate-client.d.ts +15 -0
- package/dist/operate-client.js +84 -0
- package/dist/utils.d.ts +17 -0
- package/dist/utils.js +112 -0
- package/index.js +672 -0
- package/package.json +28 -0
- package/plugins/i18n/.claude-plugin/plugin.json +8 -0
- package/plugins/i18n/.mcp.json +13 -0
- package/plugins/i18n/skills/i18n/SKILL.md +112 -0
- package/plugins/yapi/.claude-plugin/plugin.json +8 -0
- package/plugins/yapi/.mcp.json +13 -0
- package/plugins/yapi/skills/yapi/SKILL.md +76 -0
- package/setup.sh +96 -0
- package/src/auth.ts +158 -0
- package/src/index.ts +376 -0
- package/src/operate-client.ts +94 -0
- package/src/utils.test.ts +337 -0
- package/src/utils.ts +127 -0
- package/test-auth-e2e.js +278 -0
- package/tsconfig.json +17 -0
- package/update-cookie.sh +75 -0
package/test-auth-e2e.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* E2E test for SSO authentication flow.
|
|
5
|
+
*
|
|
6
|
+
* Tests:
|
|
7
|
+
* 1. Credential save / load / clear / expiry
|
|
8
|
+
* 2. Successful SSO callback → credentials saved
|
|
9
|
+
* 3. Missing token → 400 error
|
|
10
|
+
* 4. Unknown path → 404
|
|
11
|
+
* 5. CSRF token extraction from operate server
|
|
12
|
+
*
|
|
13
|
+
* Usage: node test-auth-e2e.js
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, before, after } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import http from "node:http";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
// --- Test config ---
|
|
26
|
+
const TEST_PORT = 19877;
|
|
27
|
+
const TEST_CREDENTIALS_DIR = path.join(__dirname, ".test-credentials");
|
|
28
|
+
const TEST_CREDENTIALS_FILE = path.join(TEST_CREDENTIALS_DIR, "credentials.json");
|
|
29
|
+
const FAKE_OPERATE_PORT = 19878;
|
|
30
|
+
|
|
31
|
+
// --- Credential helpers (same logic as production code) ---
|
|
32
|
+
|
|
33
|
+
function saveCredentials(creds) {
|
|
34
|
+
if (!fs.existsSync(TEST_CREDENTIALS_DIR)) {
|
|
35
|
+
fs.mkdirSync(TEST_CREDENTIALS_DIR, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
fs.writeFileSync(TEST_CREDENTIALS_FILE, JSON.stringify(creds, null, 2));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadCredentials() {
|
|
41
|
+
try {
|
|
42
|
+
if (!fs.existsSync(TEST_CREDENTIALS_FILE)) return null;
|
|
43
|
+
const data = JSON.parse(fs.readFileSync(TEST_CREDENTIALS_FILE, "utf-8"));
|
|
44
|
+
if (data.expiresAt && Date.now() < data.expiresAt) return data;
|
|
45
|
+
return null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clearCredentials() {
|
|
52
|
+
try {
|
|
53
|
+
if (fs.existsSync(TEST_CREDENTIALS_FILE)) fs.unlinkSync(TEST_CREDENTIALS_FILE);
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cleanup() {
|
|
58
|
+
try {
|
|
59
|
+
if (fs.existsSync(TEST_CREDENTIALS_FILE)) fs.unlinkSync(TEST_CREDENTIALS_FILE);
|
|
60
|
+
if (fs.existsSync(TEST_CREDENTIALS_DIR)) fs.rmdirSync(TEST_CREDENTIALS_DIR);
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** HTTP GET → { statusCode, headers, body } */
|
|
65
|
+
function httpGet(url, timeoutMs = 5000) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const req = http.get(url, { headers: { Connection: "close" } }, (res) => {
|
|
68
|
+
let body = "";
|
|
69
|
+
res.on("data", (chunk) => (body += chunk));
|
|
70
|
+
res.on("end", () =>
|
|
71
|
+
resolve({ statusCode: res.statusCode, headers: res.headers, body })
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
req.on("error", reject);
|
|
75
|
+
req.setTimeout(timeoutMs, () => { req.destroy(new Error("httpGet timeout")); });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Fake operate server (returns _csrf cookie) ---
|
|
80
|
+
|
|
81
|
+
let fakeOperateServer;
|
|
82
|
+
function startFakeOperateServer() {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
fakeOperateServer = http.createServer((req, res) => {
|
|
85
|
+
if (req.url.startsWith("/site/get-config")) {
|
|
86
|
+
res.writeHead(200, {
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"Set-Cookie": "_csrf=test_csrf_token_abc123; Path=/",
|
|
89
|
+
});
|
|
90
|
+
res.end(JSON.stringify({ code: 0 }));
|
|
91
|
+
} else {
|
|
92
|
+
res.writeHead(404);
|
|
93
|
+
res.end("Not found");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
fakeOperateServer.listen(FAKE_OPERATE_PORT, () => resolve());
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Auth callback server (mimics production logic, no auto-close) ---
|
|
101
|
+
|
|
102
|
+
function createAuthServer(baseUrl) {
|
|
103
|
+
let resolveAuth, rejectAuth;
|
|
104
|
+
const authPromise = new Promise((res, rej) => {
|
|
105
|
+
resolveAuth = res;
|
|
106
|
+
rejectAuth = rej;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const server = http.createServer(async (req, res) => {
|
|
110
|
+
const reqUrl = new URL(req.url || "/", `http://localhost:${TEST_PORT}`);
|
|
111
|
+
|
|
112
|
+
if (reqUrl.pathname === "/callback") {
|
|
113
|
+
const ssoToken = reqUrl.searchParams.get("token");
|
|
114
|
+
if (!ssoToken) {
|
|
115
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
116
|
+
res.end("<h2>登录失败:未收到 token</h2>", () => {
|
|
117
|
+
rejectAuth(new Error("No token received from SSO"));
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const csrfRes = await httpGet(`${baseUrl}/site/get-config`);
|
|
124
|
+
const cookies = csrfRes.headers["set-cookie"] || [];
|
|
125
|
+
let csrf = "";
|
|
126
|
+
for (const c of cookies) {
|
|
127
|
+
const m = c.match(/^_csrf=([^;]*)/);
|
|
128
|
+
if (m) { csrf = m[1]; break; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const creds = {
|
|
132
|
+
ssoToken,
|
|
133
|
+
csrfToken: csrf,
|
|
134
|
+
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
|
|
135
|
+
};
|
|
136
|
+
saveCredentials(creds);
|
|
137
|
+
|
|
138
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
139
|
+
res.end(
|
|
140
|
+
`<html><body><h1>登录成功</h1><p>正在返回应用…</p></body></html>` +
|
|
141
|
+
`<script>setTimeout(function(){ window.close(); }, 1000);</script>`
|
|
142
|
+
);
|
|
143
|
+
resolveAuth(creds);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
146
|
+
res.end(`<h2>登录失败:${err.message}</h2>`);
|
|
147
|
+
rejectAuth(err);
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
res.writeHead(404);
|
|
151
|
+
res.end("Not found");
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return { server, authPromise };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function listenServer(server) {
|
|
159
|
+
return new Promise((resolve) => server.listen(TEST_PORT, resolve));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function closeServer(server) {
|
|
163
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Tests ---
|
|
167
|
+
|
|
168
|
+
describe("SSO Auth E2E", () => {
|
|
169
|
+
before(async () => {
|
|
170
|
+
cleanup();
|
|
171
|
+
await startFakeOperateServer();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
after(async () => {
|
|
175
|
+
cleanup();
|
|
176
|
+
if (fakeOperateServer) await new Promise((r) => fakeOperateServer.close(r));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("Credential management", () => {
|
|
180
|
+
it("saveCredentials + loadCredentials round-trip", () => {
|
|
181
|
+
const creds = { ssoToken: "tk_1", csrfToken: "cs_1", expiresAt: Date.now() + 60000 };
|
|
182
|
+
saveCredentials(creds);
|
|
183
|
+
assert.deepStrictEqual(loadCredentials(), creds);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("expired credentials return null", () => {
|
|
187
|
+
saveCredentials({ ssoToken: "tk_exp", csrfToken: "cs_exp", expiresAt: Date.now() - 1000 });
|
|
188
|
+
assert.strictEqual(loadCredentials(), null);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("clearCredentials removes file", () => {
|
|
192
|
+
saveCredentials({ ssoToken: "tk_del", csrfToken: "cs_del", expiresAt: Date.now() + 60000 });
|
|
193
|
+
assert.ok(fs.existsSync(TEST_CREDENTIALS_FILE));
|
|
194
|
+
clearCredentials();
|
|
195
|
+
assert.ok(!fs.existsSync(TEST_CREDENTIALS_FILE));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("loadCredentials returns null when no file", () => {
|
|
199
|
+
clearCredentials();
|
|
200
|
+
assert.strictEqual(loadCredentials(), null);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("Auth callback server", () => {
|
|
205
|
+
it("successful login: returns 200, saves credentials, includes window.close()", async () => {
|
|
206
|
+
const baseUrl = `http://localhost:${FAKE_OPERATE_PORT}`;
|
|
207
|
+
const { server, authPromise } = createAuthServer(baseUrl);
|
|
208
|
+
await listenServer(server);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const res = await httpGet(`http://localhost:${TEST_PORT}/callback?token=sso_test_789`);
|
|
212
|
+
|
|
213
|
+
assert.strictEqual(res.statusCode, 200);
|
|
214
|
+
assert.ok(res.body.includes("登录成功"));
|
|
215
|
+
assert.ok(res.body.includes("window.close()"));
|
|
216
|
+
|
|
217
|
+
const creds = await authPromise;
|
|
218
|
+
assert.strictEqual(creds.ssoToken, "sso_test_789");
|
|
219
|
+
assert.strictEqual(creds.csrfToken, "test_csrf_token_abc123");
|
|
220
|
+
assert.ok(creds.expiresAt > Date.now());
|
|
221
|
+
|
|
222
|
+
// Verify persisted
|
|
223
|
+
const saved = loadCredentials();
|
|
224
|
+
assert.deepStrictEqual(saved, creds);
|
|
225
|
+
} finally {
|
|
226
|
+
await closeServer(server);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("missing token: rejects with 'No token received'", async () => {
|
|
231
|
+
const baseUrl = `http://localhost:${FAKE_OPERATE_PORT}`;
|
|
232
|
+
const { server, authPromise } = createAuthServer(baseUrl);
|
|
233
|
+
await listenServer(server);
|
|
234
|
+
|
|
235
|
+
// Run both in parallel — one may resolve/reject before the other
|
|
236
|
+
const [httpSettled, authSettled] = await Promise.allSettled([
|
|
237
|
+
httpGet(`http://localhost:${TEST_PORT}/callback`),
|
|
238
|
+
authPromise,
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
// authPromise must reject with the right error
|
|
242
|
+
assert.strictEqual(authSettled.status, "rejected");
|
|
243
|
+
assert.match(authSettled.reason.message, /No token received/);
|
|
244
|
+
|
|
245
|
+
// HTTP response: if fulfilled, should be 400
|
|
246
|
+
if (httpSettled.status === "fulfilled") {
|
|
247
|
+
assert.strictEqual(httpSettled.value.statusCode, 400);
|
|
248
|
+
assert.ok(httpSettled.value.body.includes("登录失败"));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await closeServer(server);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("unknown path: returns 404", async () => {
|
|
255
|
+
const baseUrl = `http://localhost:${FAKE_OPERATE_PORT}`;
|
|
256
|
+
const { server } = createAuthServer(baseUrl);
|
|
257
|
+
await listenServer(server);
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const res = await httpGet(`http://localhost:${TEST_PORT}/unknown`);
|
|
261
|
+
assert.strictEqual(res.statusCode, 404);
|
|
262
|
+
} finally {
|
|
263
|
+
await closeServer(server);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("CSRF token extraction", () => {
|
|
269
|
+
it("fake operate server returns _csrf in Set-Cookie", async () => {
|
|
270
|
+
const res = await httpGet(`http://localhost:${FAKE_OPERATE_PORT}/site/get-config`);
|
|
271
|
+
assert.strictEqual(res.statusCode, 200);
|
|
272
|
+
const cookies = res.headers["set-cookie"] || [];
|
|
273
|
+
const csrfCookie = cookies.find((c) => c.startsWith("_csrf="));
|
|
274
|
+
assert.ok(csrfCookie);
|
|
275
|
+
assert.ok(csrfCookie.includes("test_csrf_token_abc123"));
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
|
17
|
+
}
|
package/update-cookie.sh
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# 快速更新 Cookie 和 CSRF Token
|
|
3
|
+
# 用法: bash update-cookie.sh
|
|
4
|
+
# 或: bash update-cookie.sh "从浏览器复制的完整 curl 命令"
|
|
5
|
+
|
|
6
|
+
MCP_SERVER_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
|
|
8
|
+
echo "=== 更新多语言平台认证信息 ==="
|
|
9
|
+
echo ""
|
|
10
|
+
|
|
11
|
+
# 如果传入了 curl 命令,自动提取 cookie 和 csrf token
|
|
12
|
+
if [ -n "$1" ]; then
|
|
13
|
+
CURL_CMD="$*"
|
|
14
|
+
|
|
15
|
+
# 从 curl 命令中提取 cookie (-b 或 --cookie 或 -H 'cookie: ...')
|
|
16
|
+
COOKIE=$(echo "$CURL_CMD" | grep -oP "(?<=-b ').*?(?=')" 2>/dev/null || \
|
|
17
|
+
echo "$CURL_CMD" | sed -n "s/.*-b '\([^']*\)'.*/\1/p" 2>/dev/null || \
|
|
18
|
+
echo "$CURL_CMD" | sed -n "s/.*-b \"\([^\"]*\)\".*/\1/p" 2>/dev/null)
|
|
19
|
+
|
|
20
|
+
if [ -z "$COOKIE" ]; then
|
|
21
|
+
COOKIE=$(echo "$CURL_CMD" | sed -n "s/.*[Cc]ookie: \([^'\"]*\)['\"].*/\1/p" 2>/dev/null)
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# 从 curl 命令中提取 csrf token
|
|
25
|
+
CSRF=$(echo "$CURL_CMD" | sed -n "s/.*[Xx]-[Cc][Ss][Rr][Ff]-[Tt]oken: \([^'\"]*\)['\"].*/\1/p" 2>/dev/null)
|
|
26
|
+
|
|
27
|
+
if [ -n "$COOKIE" ]; then
|
|
28
|
+
echo "$COOKIE" > "$MCP_SERVER_DIR/.cookie"
|
|
29
|
+
echo "Cookie 已更新 (${#COOKIE} 字符)"
|
|
30
|
+
else
|
|
31
|
+
echo "未能从 curl 命令中提取 Cookie,请手动输入"
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if [ -n "$CSRF" ]; then
|
|
35
|
+
echo "$CSRF" > "$MCP_SERVER_DIR/.csrf-token"
|
|
36
|
+
echo "CSRF Token 已更新"
|
|
37
|
+
else
|
|
38
|
+
echo "未能从 curl 命令中提取 CSRF Token,请手动输入"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [ -n "$COOKIE" ] && [ -n "$CSRF" ]; then
|
|
42
|
+
echo ""
|
|
43
|
+
echo "更新完成!请重启 Claude Code 使其生效。"
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
echo ""
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# 交互式输入
|
|
50
|
+
echo "获取方式:"
|
|
51
|
+
echo " 1. 浏览器打开 https://operate-test.intsig.net/multilanguage"
|
|
52
|
+
echo " 2. 登录后打开 DevTools → Network"
|
|
53
|
+
echo " 3. 做一次搜索操作,找到 get-string-search 请求"
|
|
54
|
+
echo " 4. 右键 → Copy → Copy as cURL"
|
|
55
|
+
echo ""
|
|
56
|
+
|
|
57
|
+
if [ -z "$COOKIE" ]; then
|
|
58
|
+
read -p "粘贴 Cookie 值: " COOKIE
|
|
59
|
+
if [ -n "$COOKIE" ]; then
|
|
60
|
+
echo "$COOKIE" > "$MCP_SERVER_DIR/.cookie"
|
|
61
|
+
echo "Cookie 已保存 (${#COOKIE} 字符)"
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
if [ -z "$CSRF" ]; then
|
|
66
|
+
echo ""
|
|
67
|
+
read -p "粘贴 X-CSRF-Token 值: " CSRF
|
|
68
|
+
if [ -n "$CSRF" ]; then
|
|
69
|
+
echo "$CSRF" > "$MCP_SERVER_DIR/.csrf-token"
|
|
70
|
+
echo "CSRF Token 已保存"
|
|
71
|
+
fi
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
echo ""
|
|
75
|
+
echo "更新完成!请重启 Claude Code 使其生效。"
|