@lessie/mcp-server 0.0.6 → 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/README.md +75 -14
- package/SKILL.md +43 -122
- package/dist/auth.js +256 -0
- package/dist/config.js +29 -0
- package/dist/index.js +66 -104
- package/dist/process-handlers.js +10 -0
- package/dist/remote.js +267 -0
- package/dist/schema.js +71 -0
- package/dist/tools.js +145 -0
- package/mcpb/.mcpbignore +4 -0
- package/mcpb/README.md +47 -0
- package/mcpb/lessie-mcp-20260320-221003.mcpb +0 -0
- package/mcpb/lessie-mcp-20260320-232505.mcpb +0 -0
- package/mcpb/lessie-mcp-20260320-232558.mcpb +0 -0
- package/mcpb/manifest.json +45 -0
- package/mcpb/pack.sh +36 -0
- package/package.json +7 -2
- package/dist/server/auth.js +0 -36
- package/dist/server/db.js +0 -5
- package/dist/server/index.js +0 -11
- package/dist/server/routes/api-keys.js +0 -78
- package/dist/server/routes/auth-token.js +0 -41
package/dist/index.js
CHANGED
|
@@ -1,115 +1,77 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
|
-
const require = createRequire(import.meta.url);
|
|
7
|
-
const pkg = require("../package.json");
|
|
8
|
-
function loadInstructions() {
|
|
9
|
-
try {
|
|
10
|
-
const raw = readFileSync(new URL("../SKILL.md", import.meta.url), "utf-8");
|
|
11
|
-
return raw.replace(/^---[\s\S]*?---\n*/, "").trim() || undefined;
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
// ── 环境变量 ──────────────────────────────────────────────────────────────────
|
|
18
|
-
const BASE_URL = process.env.LESSIE_BASE_URL || 'https://www.lessie.ai/prod-api';
|
|
19
|
-
const API_KEY = process.env.LESSIE_API_KEY;
|
|
20
|
-
if (!BASE_URL) {
|
|
21
|
-
console.error("Error: LESSIE_BASE_URL is not set");
|
|
22
|
-
process.exit(1);
|
|
23
|
-
}
|
|
24
|
-
if (!API_KEY) {
|
|
25
|
-
console.error("Error: LESSIE_API_KEY is not set");
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
let jwtCache = null;
|
|
29
1
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
2
|
+
* 入口:创建 MCP Server,注册工具,启动 stdio 传输。
|
|
3
|
+
*
|
|
4
|
+
* 工具路由策略:
|
|
5
|
+
* authorize → 发起 OAuth 授权流程
|
|
6
|
+
* use_lessie → 代理调用远程 MCP Server 上的工具
|
|
32
7
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
body: JSON.stringify({ apiKey: API_KEY }),
|
|
43
|
-
});
|
|
44
|
-
if (!res.ok) {
|
|
45
|
-
throw new Error(`Failed to exchange API key for JWT: ${res.status} ${res.statusText} ${BASE_URL}/auth/token`);
|
|
46
|
-
}
|
|
47
|
-
const body = (await res.json());
|
|
48
|
-
if (!body.data?.token) {
|
|
49
|
-
throw new Error(`/auth/token response missing data.token. Got: ${JSON.stringify(body)}`);
|
|
50
|
-
}
|
|
51
|
-
jwtCache = {
|
|
52
|
-
token: body.data.token,
|
|
53
|
-
expiry: now + body.data.expiresIn * 1000,
|
|
54
|
-
};
|
|
55
|
-
return jwtCache.token;
|
|
56
|
-
}
|
|
57
|
-
async function api(method, path, body) {
|
|
58
|
-
const token = await getJwt();
|
|
59
|
-
const res = await fetch(`${BASE_URL}${path}`, {
|
|
60
|
-
method,
|
|
61
|
-
headers: {
|
|
62
|
-
"Authorization": token,
|
|
63
|
-
"Content-Type": "application/json",
|
|
64
|
-
},
|
|
65
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
66
|
-
});
|
|
67
|
-
if (!res.ok) {
|
|
68
|
-
const text = await res.text().catch(() => "");
|
|
69
|
-
throw new Error(`API error ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
|
|
70
|
-
}
|
|
71
|
-
if (res.status === 204)
|
|
72
|
-
return undefined;
|
|
73
|
-
const wrapper = (await res.json());
|
|
74
|
-
return wrapper.data;
|
|
75
|
-
}
|
|
76
|
-
// ── 工具结果辅助函数 ───────────────────────────────────────────────────────────
|
|
77
|
-
function textResult(data) {
|
|
78
|
-
return {
|
|
79
|
-
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
83
|
-
const server = new McpServer({
|
|
84
|
-
name: "lessie-mcp",
|
|
85
|
-
version: pkg.version,
|
|
86
|
-
}, {
|
|
8
|
+
import "./process-handlers.js";
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { REMOTE_MCP_URL, pkg, loadInstructions } from "./config.js";
|
|
12
|
+
import { connectToRemote, listRemoteTools, isRemoteConnected, setOnAuthComplete, setOnAuthError } from "./remote.js";
|
|
13
|
+
import { registerTools } from "./tools.js";
|
|
14
|
+
console.error("[lessie] Modules loaded");
|
|
15
|
+
const server = new McpServer({ name: "lessie-mcp", version: pkg.version }, {
|
|
16
|
+
capabilities: { tools: { listChanged: true } },
|
|
87
17
|
instructions: loadInstructions(),
|
|
88
18
|
});
|
|
89
|
-
|
|
90
|
-
// 命名规范:下划线分隔,动词开头(如 list_xxx、get_xxx、create_xxx)。
|
|
91
|
-
// 写入类工具在 description 中注明"执行前请向用户确认"。
|
|
92
|
-
server.tool("get_jwt", "获取当前有效的 JWT token,可用于直接调用 Lessie API。返回 token 字符串和剩余有效时间。", {}, async () => {
|
|
93
|
-
const token = await getJwt();
|
|
94
|
-
const remainingMs = jwtCache ? jwtCache.expiry - Date.now() : 0;
|
|
95
|
-
return textResult({
|
|
96
|
-
token,
|
|
97
|
-
remainingSeconds: Math.max(0, Math.floor(remainingMs / 1000)),
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
server.tool("get_account_info", "查看当前 Lessie 账号的详细信息,包括用户名、邮箱、账号状态、角色、邀请码等。", {}, async () => {
|
|
101
|
-
const data = await api("GET", "/agent/account/info");
|
|
102
|
-
return textResult(data);
|
|
103
|
-
});
|
|
19
|
+
registerTools(server);
|
|
104
20
|
// ── 启动 ──────────────────────────────────────────────────────────────────────
|
|
21
|
+
console.error("[lessie] Creating transport...");
|
|
105
22
|
const transport = new StdioServerTransport();
|
|
23
|
+
console.error("[lessie] Connecting server...");
|
|
106
24
|
await server.connect(transport);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
25
|
+
console.error("[lessie] Server connected, setting up auth callback...");
|
|
26
|
+
setOnAuthComplete((toolCount) => {
|
|
27
|
+
server.sendLoggingMessage({
|
|
28
|
+
level: "info",
|
|
29
|
+
logger: "lessie",
|
|
30
|
+
data: `Authorization successful. Connected to remote MCP server. Discovered ${toolCount} tools.`,
|
|
31
|
+
});
|
|
32
|
+
server.sendToolListChanged();
|
|
33
|
+
});
|
|
34
|
+
setOnAuthError(({ code, message }) => {
|
|
35
|
+
const hint = code === "timeout"
|
|
36
|
+
? "Please call the 'authorize' tool again to generate a new authorization link."
|
|
37
|
+
: code === "port_in_use"
|
|
38
|
+
? `Port conflict detected. Check for processes using the callback port, then call 'authorize' to retry.`
|
|
39
|
+
: "Please call the 'authorize' tool to retry.";
|
|
40
|
+
server.sendLoggingMessage({
|
|
41
|
+
level: "error",
|
|
42
|
+
logger: "lessie",
|
|
43
|
+
data: `Authorization failed: ${message}. ${hint}`,
|
|
44
|
+
});
|
|
112
45
|
});
|
|
46
|
+
console.error("[lessie] Connecting to remote...");
|
|
47
|
+
try {
|
|
48
|
+
await connectToRemote();
|
|
49
|
+
console.error("[lessie] connectToRemote() completed, connected:", isRemoteConnected());
|
|
50
|
+
if (isRemoteConnected()) {
|
|
51
|
+
const remoteTools = await listRemoteTools();
|
|
52
|
+
server.sendLoggingMessage({
|
|
53
|
+
level: "info",
|
|
54
|
+
logger: "lessie",
|
|
55
|
+
data: `Connected to remote MCP server (${REMOTE_MCP_URL}). Discovered ${remoteTools.length} tools.`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else if (REMOTE_MCP_URL) {
|
|
59
|
+
server.sendLoggingMessage({
|
|
60
|
+
level: "warning",
|
|
61
|
+
logger: "lessie",
|
|
62
|
+
data: "Remote MCP server requires authorization. Use the 'authorize' tool to connect.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error("[lessie] connectToRemote() threw:", err);
|
|
68
|
+
server.sendLoggingMessage({
|
|
69
|
+
level: "error",
|
|
70
|
+
logger: "lessie",
|
|
71
|
+
data: `Failed to connect to remote MCP server: ${err}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
console.error("[lessie] Startup complete");
|
|
113
75
|
server.sendLoggingMessage({
|
|
114
76
|
level: "info",
|
|
115
77
|
logger: "lessie",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
process.on("uncaughtException", (err) => {
|
|
2
|
+
console.error("[lessie] Uncaught exception:", err);
|
|
3
|
+
});
|
|
4
|
+
process.on("unhandledRejection", (reason) => {
|
|
5
|
+
console.error("[lessie] Unhandled rejection:", reason);
|
|
6
|
+
});
|
|
7
|
+
process.on("exit", (code) => {
|
|
8
|
+
console.error(`[lessie] Process exiting with code ${code}`);
|
|
9
|
+
});
|
|
10
|
+
export {};
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程 MCP 代理客户端。
|
|
3
|
+
*
|
|
4
|
+
* 以 MCP Client 身份连接远程 MCP Server,发现其工具并代理调用。
|
|
5
|
+
* 传输层使用 Streamable HTTP。
|
|
6
|
+
*
|
|
7
|
+
* 鉴权策略:
|
|
8
|
+
* 1. 启动时检查本地缓存令牌,有才尝试连接,无则跳过
|
|
9
|
+
* 2. 用户通过 authorize 工具触发 OAuth 流程(直接调用 SDK auth())
|
|
10
|
+
* 3. 用户在浏览器完成登录后,后台自动交换令牌并连接
|
|
11
|
+
*/
|
|
12
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
13
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
14
|
+
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
15
|
+
import { REMOTE_MCP_URL, pkg } from "./config.js";
|
|
16
|
+
import { authProvider } from "./auth.js";
|
|
17
|
+
const DEBUG = !!process.env.DEBUG_OAUTH;
|
|
18
|
+
const debugFetch = async (input, init) => {
|
|
19
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
20
|
+
const method = init?.method ?? "GET";
|
|
21
|
+
console.error(`[OAuth] --> ${method} ${url}`);
|
|
22
|
+
if (init?.headers)
|
|
23
|
+
console.error(`[OAuth] headers:`, JSON.stringify(init.headers));
|
|
24
|
+
if (init?.body)
|
|
25
|
+
console.error(`[OAuth] body:`, String(init.body));
|
|
26
|
+
const res = await fetch(input, init);
|
|
27
|
+
const cloned = res.clone();
|
|
28
|
+
const text = await cloned.text().catch(() => "");
|
|
29
|
+
console.error(`[OAuth] <-- ${res.status} ${res.statusText}`);
|
|
30
|
+
if (text)
|
|
31
|
+
console.error(`[OAuth] response:`, text.slice(0, 2000));
|
|
32
|
+
return res;
|
|
33
|
+
};
|
|
34
|
+
const fetchFn = DEBUG ? debugFetch : undefined;
|
|
35
|
+
let client = null;
|
|
36
|
+
let cachedTools = [];
|
|
37
|
+
let reconnecting = null;
|
|
38
|
+
let authCompletion = null;
|
|
39
|
+
let onAuthComplete = null;
|
|
40
|
+
let onAuthError = null;
|
|
41
|
+
let lastAuthError = null;
|
|
42
|
+
/** 注册授权完成回调(用于发送 logging 通知) */
|
|
43
|
+
export function setOnAuthComplete(cb) {
|
|
44
|
+
onAuthComplete = cb;
|
|
45
|
+
}
|
|
46
|
+
/** 注册授权失败回调(用于主动通知 Agent) */
|
|
47
|
+
export function setOnAuthError(cb) {
|
|
48
|
+
onAuthError = cb;
|
|
49
|
+
}
|
|
50
|
+
export function isRemoteConnected() {
|
|
51
|
+
return client !== null;
|
|
52
|
+
}
|
|
53
|
+
/** 若授权流程正在进行中,等待其完成(成功或失败) */
|
|
54
|
+
export async function waitForAuthCompletion() {
|
|
55
|
+
if (authCompletion)
|
|
56
|
+
await authCompletion;
|
|
57
|
+
}
|
|
58
|
+
/** 启动时静默连接:有缓存令牌才尝试,无令牌或连接失败则跳过 */
|
|
59
|
+
export async function connectToRemote() {
|
|
60
|
+
if (!REMOTE_MCP_URL)
|
|
61
|
+
return;
|
|
62
|
+
if (!(await authProvider.tokens()))
|
|
63
|
+
return;
|
|
64
|
+
const url = new URL(REMOTE_MCP_URL);
|
|
65
|
+
try {
|
|
66
|
+
client = await tryConnect(url);
|
|
67
|
+
const { tools } = await client.listTools();
|
|
68
|
+
cachedTools = tools;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
client = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 发起 OAuth 授权流程,返回结构化结果。
|
|
76
|
+
*
|
|
77
|
+
* 状态说明:
|
|
78
|
+
* connected — 已连接远程服务器
|
|
79
|
+
* auth_url — 生成了授权链接,等待用户浏览器完成登录
|
|
80
|
+
* waiting — 授权流程进行中,正在等待浏览器回调
|
|
81
|
+
* error — 授权流程出错(附带错误码和引导信息)
|
|
82
|
+
*/
|
|
83
|
+
export async function initiateAuth() {
|
|
84
|
+
if (client) {
|
|
85
|
+
const tools = await listRemoteTools();
|
|
86
|
+
return { status: "connected", toolCount: tools.length };
|
|
87
|
+
}
|
|
88
|
+
if (!REMOTE_MCP_URL) {
|
|
89
|
+
return { status: "error", errorCode: "not_configured", message: "LESSIE_REMOTE_MCP_URL is not configured" };
|
|
90
|
+
}
|
|
91
|
+
// 授权流程进行中:告诉 Agent 当前等待状态
|
|
92
|
+
if (authProvider.pendingAuthUrl && authCompletion) {
|
|
93
|
+
const elapsed = authProvider.waitingSince
|
|
94
|
+
? Date.now() - authProvider.waitingSince
|
|
95
|
+
: 0;
|
|
96
|
+
return {
|
|
97
|
+
status: "waiting",
|
|
98
|
+
authUrl: authProvider.pendingAuthUrl,
|
|
99
|
+
elapsedMs: elapsed,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// 上次异步错误(超时、授权拒绝等),记录后清除
|
|
103
|
+
const prevError = lastAuthError;
|
|
104
|
+
lastAuthError = null;
|
|
105
|
+
const serverUrl = REMOTE_MCP_URL;
|
|
106
|
+
try {
|
|
107
|
+
await authProvider.prepareCallbackServer();
|
|
108
|
+
const result = await auth(authProvider, { serverUrl, fetchFn });
|
|
109
|
+
if (result === "AUTHORIZED") {
|
|
110
|
+
const url = new URL(serverUrl);
|
|
111
|
+
client = await tryConnect(url);
|
|
112
|
+
const { tools } = await client.listTools();
|
|
113
|
+
cachedTools = tools;
|
|
114
|
+
return { status: "connected", toolCount: tools.length };
|
|
115
|
+
}
|
|
116
|
+
const authUrl = authProvider.pendingAuthUrl;
|
|
117
|
+
if (!authUrl) {
|
|
118
|
+
return { status: "error", errorCode: "no_auth_url", message: "OAuth flow initiated but no authorization URL was generated" };
|
|
119
|
+
}
|
|
120
|
+
ensureAuthCompletion();
|
|
121
|
+
return {
|
|
122
|
+
status: "auth_url",
|
|
123
|
+
authUrl,
|
|
124
|
+
...(prevError ? { previousError: prevError.message } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
129
|
+
return { status: "error", errorCode: categorizeAuthError(error), message: error.message };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** 启动后台任务:等待用户浏览器回调 → 交换令牌 → 连接远程服务器 */
|
|
133
|
+
function ensureAuthCompletion() {
|
|
134
|
+
if (authCompletion)
|
|
135
|
+
return;
|
|
136
|
+
const serverUrl = REMOTE_MCP_URL;
|
|
137
|
+
const url = new URL(serverUrl);
|
|
138
|
+
authCompletion = (async () => {
|
|
139
|
+
try {
|
|
140
|
+
const code = await authProvider.waitForCallback();
|
|
141
|
+
await auth(authProvider, { serverUrl, authorizationCode: code, fetchFn });
|
|
142
|
+
client = await tryConnect(url);
|
|
143
|
+
const { tools } = await client.listTools();
|
|
144
|
+
cachedTools = tools;
|
|
145
|
+
authProvider.pendingAuthUrl = null;
|
|
146
|
+
onAuthComplete?.(cachedTools.length);
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
authProvider.pendingAuthUrl = null;
|
|
150
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
151
|
+
const code = categorizeAuthError(error);
|
|
152
|
+
lastAuthError = { code, message: error.message };
|
|
153
|
+
onAuthError?.({ code, message: error.message });
|
|
154
|
+
console.error("[lessie] Authorization completion failed:", e);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
authCompletion = null;
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
}
|
|
161
|
+
function categorizeAuthError(err) {
|
|
162
|
+
const msg = err.message.toLowerCase();
|
|
163
|
+
const code = err.code;
|
|
164
|
+
if (code === "EADDRINUSE" || msg.includes("already in use") || msg.includes("no available port"))
|
|
165
|
+
return "port_in_use";
|
|
166
|
+
if (msg.includes("timed out") || msg.includes("timeout"))
|
|
167
|
+
return "timeout";
|
|
168
|
+
if (msg.includes("authorization denied") || msg.includes("access_denied"))
|
|
169
|
+
return "auth_denied";
|
|
170
|
+
if (msg.includes("callback server"))
|
|
171
|
+
return "server_error";
|
|
172
|
+
return "unknown";
|
|
173
|
+
}
|
|
174
|
+
async function tryConnect(url) {
|
|
175
|
+
const c = new Client({ name: "lessie-mcp-proxy", version: pkg.version });
|
|
176
|
+
await c.connect(new StreamableHTTPClientTransport(url, { authProvider, fetch: fetchFn }));
|
|
177
|
+
return c;
|
|
178
|
+
}
|
|
179
|
+
/** 获取远程工具列表;连接可用时实时刷新,否则返回缓存 */
|
|
180
|
+
export async function listRemoteTools() {
|
|
181
|
+
if (!client)
|
|
182
|
+
return cachedTools;
|
|
183
|
+
try {
|
|
184
|
+
const { tools } = await client.listTools();
|
|
185
|
+
cachedTools = tools;
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
console.warn("[lessie] Failed to refresh remote tools, using cache:", String(err));
|
|
189
|
+
}
|
|
190
|
+
return cachedTools;
|
|
191
|
+
}
|
|
192
|
+
/** 转发工具调用到远程 MCP Server;失败时引导用户重新授权 */
|
|
193
|
+
export async function callRemoteTool(name, args) {
|
|
194
|
+
if (authCompletion)
|
|
195
|
+
await authCompletion;
|
|
196
|
+
if (!client) {
|
|
197
|
+
return {
|
|
198
|
+
content: [
|
|
199
|
+
{
|
|
200
|
+
type: "text",
|
|
201
|
+
text: `无法调用工具 ${name}:远程 MCP 服务器未连接。请先使用 authorize 工具完成登录授权。`,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
isError: true,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
return (await client.callTool({ name, arguments: args }));
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
if (!isAuthError(err)) {
|
|
212
|
+
return {
|
|
213
|
+
content: [
|
|
214
|
+
{
|
|
215
|
+
type: "text",
|
|
216
|
+
text: `工具 ${name} 调用失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
isError: true,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
await reconnect();
|
|
223
|
+
if (!client) {
|
|
224
|
+
return {
|
|
225
|
+
content: [
|
|
226
|
+
{
|
|
227
|
+
type: "text",
|
|
228
|
+
text: `工具 ${name} 调用失败(认证过期),请使用 authorize 工具重新登录。`,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
isError: true,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return (await client.callTool({ name, arguments: args }));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function isAuthError(err) {
|
|
238
|
+
const obj = err;
|
|
239
|
+
const status = obj?.status ?? obj?.statusCode;
|
|
240
|
+
if (status === 401 || status === 403)
|
|
241
|
+
return true;
|
|
242
|
+
if (err instanceof Error) {
|
|
243
|
+
const msg = err.message.toLowerCase();
|
|
244
|
+
return msg.includes("unauthorized") || msg.includes("forbidden");
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 重连,关闭旧连接;并发调用共享同一个 Promise 避免竞争。
|
|
250
|
+
* 静默尝试连接,若令牌已失效则 client 保持 null。
|
|
251
|
+
*/
|
|
252
|
+
async function reconnect() {
|
|
253
|
+
if (reconnecting)
|
|
254
|
+
return reconnecting;
|
|
255
|
+
reconnecting = (async () => {
|
|
256
|
+
try {
|
|
257
|
+
const old = client;
|
|
258
|
+
client = null;
|
|
259
|
+
await old?.close().catch(() => { });
|
|
260
|
+
await connectToRemote();
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
reconnecting = null;
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
266
|
+
return reconnecting;
|
|
267
|
+
}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema (MCP Tool inputSchema) → Zod shape 转换。
|
|
3
|
+
*
|
|
4
|
+
* MCP 工具的 inputSchema 是 JSON Schema 的受限子集(type: "object"),
|
|
5
|
+
* 这里只处理 properties 中可能出现的常见类型。
|
|
6
|
+
* 不支持的类型回退为 z.unknown()。
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
function convertProperty(prop) {
|
|
10
|
+
const types = Array.isArray(prop.type) ? prop.type : prop.type ? [prop.type] : [];
|
|
11
|
+
const hasNull = types.includes("null");
|
|
12
|
+
const nonNullTypes = types.filter((t) => t !== "null");
|
|
13
|
+
const primaryType = nonNullTypes[0];
|
|
14
|
+
let schema;
|
|
15
|
+
if (prop.enum && Array.isArray(prop.enum)) {
|
|
16
|
+
const values = prop.enum.filter((v) => v !== null);
|
|
17
|
+
schema = values.length > 0 ? z.enum(values) : z.unknown();
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
switch (primaryType) {
|
|
21
|
+
case "string":
|
|
22
|
+
schema = z.string();
|
|
23
|
+
break;
|
|
24
|
+
case "number":
|
|
25
|
+
case "integer":
|
|
26
|
+
schema = z.number();
|
|
27
|
+
break;
|
|
28
|
+
case "boolean":
|
|
29
|
+
schema = z.boolean();
|
|
30
|
+
break;
|
|
31
|
+
case "array":
|
|
32
|
+
schema = prop.items ? z.array(convertProperty(prop.items)) : z.array(z.unknown());
|
|
33
|
+
break;
|
|
34
|
+
case "object":
|
|
35
|
+
if (prop.properties) {
|
|
36
|
+
schema = z.object(convertShape(prop.properties, prop.required));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
schema = z.record(z.unknown());
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
default:
|
|
43
|
+
schema = z.unknown();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (hasNull)
|
|
47
|
+
schema = schema.nullable();
|
|
48
|
+
if (prop.description)
|
|
49
|
+
schema = schema.describe(prop.description);
|
|
50
|
+
return schema;
|
|
51
|
+
}
|
|
52
|
+
function convertShape(properties, required) {
|
|
53
|
+
const requiredSet = new Set(required ?? []);
|
|
54
|
+
const shape = {};
|
|
55
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
56
|
+
let field = convertProperty(prop);
|
|
57
|
+
if (!requiredSet.has(key))
|
|
58
|
+
field = field.optional();
|
|
59
|
+
shape[key] = field;
|
|
60
|
+
}
|
|
61
|
+
return shape;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 将 MCP Tool 的 inputSchema(JSON Schema object)转换为 Zod raw shape。
|
|
65
|
+
* 返回的 shape 可以直接传给 McpServer.registerTool() 的 inputSchema 参数。
|
|
66
|
+
*/
|
|
67
|
+
export function jsonSchemaToZodShape(inputSchema) {
|
|
68
|
+
if (!inputSchema.properties || Object.keys(inputSchema.properties).length === 0)
|
|
69
|
+
return undefined;
|
|
70
|
+
return convertShape(inputSchema.properties, inputSchema.required);
|
|
71
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地工具注册。
|
|
3
|
+
*
|
|
4
|
+
* 定义不依赖远程 MCP Server 的工具(如鉴权辅助工具)。
|
|
5
|
+
* 新增工具时在 registerTools() 中通过 server.registerTool() 注册。
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { initiateAuth, listRemoteTools, callRemoteTool, isRemoteConnected, waitForAuthCompletion } from "./remote.js";
|
|
9
|
+
import { DEFAULT_CALLBACK_PORT, PORT_SCAN_RANGE } from "./auth.js";
|
|
10
|
+
function portInUseGuidance() {
|
|
11
|
+
const endPort = DEFAULT_CALLBACK_PORT + PORT_SCAN_RANGE - 1;
|
|
12
|
+
return [
|
|
13
|
+
`授权回调服务器启动失败:端口 ${DEFAULT_CALLBACK_PORT}–${endPort} 均被占用。`,
|
|
14
|
+
"",
|
|
15
|
+
"可能原因:上次授权流程未正常关闭,或其他程序占用了这些端口。",
|
|
16
|
+
`请执行 \`lsof -i :${DEFAULT_CALLBACK_PORT}-${endPort}\` 查看占用进程并终止,然后重新调用此工具重试。`,
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
19
|
+
const ERROR_GUIDANCE = {
|
|
20
|
+
port_in_use: portInUseGuidance,
|
|
21
|
+
timeout: [
|
|
22
|
+
"授权超时:2 分钟内未收到浏览器回调。",
|
|
23
|
+
"",
|
|
24
|
+
"可能原因:",
|
|
25
|
+
" - 用户未在浏览器中完成授权流程",
|
|
26
|
+
" - 浏览器页面已关闭或未正确加载",
|
|
27
|
+
" - 网络连接问题导致回调未到达",
|
|
28
|
+
"",
|
|
29
|
+
"请重新调用此工具生成新的授权链接,并提醒用户在浏览器中完成授权。",
|
|
30
|
+
].join("\n"),
|
|
31
|
+
auth_denied: [
|
|
32
|
+
"用户拒绝了授权请求。",
|
|
33
|
+
"",
|
|
34
|
+
"如果用户需要使用远程功能,请重新调用此工具生成新的授权链接,",
|
|
35
|
+
"并请用户在浏览器中点击「允许」完成授权。",
|
|
36
|
+
].join("\n"),
|
|
37
|
+
server_error: [
|
|
38
|
+
"授权回调服务器运行异常。",
|
|
39
|
+
"",
|
|
40
|
+
"请重新调用此工具重试。如果问题持续出现,可能需要检查系统网络配置或防火墙设置。",
|
|
41
|
+
].join("\n"),
|
|
42
|
+
not_configured: [
|
|
43
|
+
"远程 MCP 服务器地址未配置。",
|
|
44
|
+
"",
|
|
45
|
+
"请检查环境变量 LESSIE_REMOTE_MCP_URL 是否正确设置。",
|
|
46
|
+
].join("\n"),
|
|
47
|
+
};
|
|
48
|
+
function formatAuthResult(result) {
|
|
49
|
+
switch (result.status) {
|
|
50
|
+
case "connected":
|
|
51
|
+
return {
|
|
52
|
+
content: [{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: `已连接到远程 MCP 服务器,共发现 ${result.toolCount} 个远程工具,无需重新授权。\n\n可通过 use_lessie 工具调用远程功能:不传 tool 参数可列出所有工具,传入 tool 和 arguments 可调用指定工具。`,
|
|
55
|
+
}],
|
|
56
|
+
};
|
|
57
|
+
case "auth_url": {
|
|
58
|
+
const lines = [];
|
|
59
|
+
if (result.previousError) {
|
|
60
|
+
lines.push(`⚠ 上次授权尝试失败:${result.previousError}`);
|
|
61
|
+
lines.push("已自动发起新的授权流程。");
|
|
62
|
+
lines.push("");
|
|
63
|
+
}
|
|
64
|
+
lines.push("需要授权登录。请在浏览器中打开以下链接完成授权:", "", result.authUrl, "", "授权完成后,使用 use_lessie 工具调用远程功能(不传 tool 参数可查看所有可用工具)。", `如果 2 分钟内未完成授权,流程将超时,届时请重新调用此工具重试。`);
|
|
65
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
66
|
+
}
|
|
67
|
+
case "waiting": {
|
|
68
|
+
const elapsedSec = Math.floor(result.elapsedMs / 1000);
|
|
69
|
+
const remainingSec = Math.max(0, 120 - elapsedSec);
|
|
70
|
+
return {
|
|
71
|
+
content: [{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: [
|
|
74
|
+
`授权流程进行中,正在等待用户在浏览器中完成登录。`,
|
|
75
|
+
`已等待 ${elapsedSec} 秒,剩余超时时间约 ${remainingSec} 秒。`,
|
|
76
|
+
"",
|
|
77
|
+
"请提醒用户在浏览器中完成授权。如果用户已关闭页面,可以重新访问以下链接:",
|
|
78
|
+
"",
|
|
79
|
+
result.authUrl,
|
|
80
|
+
].join("\n"),
|
|
81
|
+
}],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
case "error": {
|
|
85
|
+
const entry = ERROR_GUIDANCE[result.errorCode];
|
|
86
|
+
const guidance = typeof entry === "function" ? entry()
|
|
87
|
+
: entry ?? `授权失败:${result.message}\n\n请重新调用此工具重试。`;
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: guidance }],
|
|
90
|
+
isError: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function registerTools(server) {
|
|
96
|
+
server.registerTool("authorize", {
|
|
97
|
+
description: "连接到远程 Lessie 服务。首次使用或授权过期时返回授权链接,用户需在浏览器中打开完成登录。已连接时返回当前状态。授权异常时返回诊断信息和修复建议。",
|
|
98
|
+
}, async () => {
|
|
99
|
+
const result = await initiateAuth();
|
|
100
|
+
return formatAuthResult(result);
|
|
101
|
+
});
|
|
102
|
+
server.registerTool("use_lessie", {
|
|
103
|
+
description: "调用远程 Lessie 工具的统一入口。不传 tool 参数时列出所有可用的远程工具及其参数说明;传入 tool 和 arguments 时调用指定的远程工具。需先通过 authorize 完成授权。",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
tool: z.string().optional().describe("要调用的远程工具名称,留空则列出所有可用工具"),
|
|
106
|
+
arguments: z.record(z.unknown()).optional().describe("传递给远程工具的参数"),
|
|
107
|
+
},
|
|
108
|
+
}, async (args) => {
|
|
109
|
+
const toolName = args.tool;
|
|
110
|
+
await waitForAuthCompletion();
|
|
111
|
+
if (!isRemoteConnected()) {
|
|
112
|
+
return {
|
|
113
|
+
content: [{
|
|
114
|
+
type: "text",
|
|
115
|
+
text: "远程 MCP 服务器未连接。请先调用 authorize 工具完成授权。",
|
|
116
|
+
}],
|
|
117
|
+
isError: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!toolName) {
|
|
121
|
+
const tools = await listRemoteTools();
|
|
122
|
+
if (tools.length === 0) {
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: "text", text: "当前没有可用的远程工具。" }],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const listing = tools.map((t) => {
|
|
128
|
+
const params = t.inputSchema?.properties
|
|
129
|
+
? Object.entries(t.inputSchema.properties)
|
|
130
|
+
.map(([k, v]) => ` - ${k} (${v.type ?? "any"}): ${v.description ?? ""}`)
|
|
131
|
+
.join("\n")
|
|
132
|
+
: " (无参数)";
|
|
133
|
+
const required = t.inputSchema?.required?.join(", ") ?? "";
|
|
134
|
+
return `## ${t.name}\n${t.description ?? ""}\n 参数:\n${params}${required ? `\n 必填: ${required}` : ""}`;
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
content: [{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: `共 ${tools.length} 个远程工具可用:\n\n${listing.join("\n\n")}`,
|
|
140
|
+
}],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return callRemoteTool(toolName, args.arguments ?? {});
|
|
144
|
+
});
|
|
145
|
+
}
|
package/mcpb/.mcpbignore
ADDED