@lessie/mcp-server 0.0.5 → 0.0.8
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 +62 -0
- package/dist/auth.js +217 -0
- package/dist/config.js +30 -0
- package/dist/index.js +59 -84
- package/dist/remote.js +192 -0
- package/dist/tools.js +64 -0
- package/docs/oauth-client-guide.md +354 -0
- package/package.json +3 -2
- package/.cursor/skills/add-mcp-tool/SKILL.md +0 -132
package/README.md
CHANGED
|
@@ -2,11 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
将 [Lessie](https://lessie.com) 接入 Claude Desktop,通过自然语言操作你的 Lessie 账号。
|
|
4
4
|
|
|
5
|
+
## 架构
|
|
6
|
+
|
|
7
|
+
本地 MCP Server 同时充当**代理网关**:通过 stdio 与 Claude Desktop 通信,
|
|
8
|
+
连接远程 MCP Server,将其工具合并暴露给 AI Agent。
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
Claude Desktop
|
|
12
|
+
│ stdio
|
|
13
|
+
▼
|
|
14
|
+
┌──────────────────────────────┐
|
|
15
|
+
│ 本地 MCP Server │
|
|
16
|
+
│ │
|
|
17
|
+
│ 本地工具(get_access_token) │
|
|
18
|
+
│ + │ Streamable HTTP / SSE
|
|
19
|
+
│ 远程工具代理 ───────────────│──────► 远程 MCP Server
|
|
20
|
+
│ │ Authorization: Bearer <token>
|
|
21
|
+
└──────────────────────────────┘
|
|
22
|
+
OAuth 2.1 Authorization Code + PKCE
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 鉴权机制
|
|
26
|
+
|
|
27
|
+
采用 **OAuth 2.1 Authorization Code + PKCE** 流程,实现了 MCP SDK 的 `OAuthClientProvider` 接口(`src/auth.ts`)。
|
|
28
|
+
|
|
29
|
+
**首次连接时:**
|
|
30
|
+
|
|
31
|
+
1. MCP Client 向远程服务器发送请求,收到 401
|
|
32
|
+
2. SDK 发现 OAuth 元数据(`/.well-known/oauth-authorization-server`)
|
|
33
|
+
3. SDK 动态注册客户端(`POST /register`,RFC 7591)
|
|
34
|
+
4. 自动打开浏览器,引导用户到 SaaS 登录页面
|
|
35
|
+
5. 用户登录并授权后,浏览器重定向到本地 `http://127.0.0.1:19836/callback`
|
|
36
|
+
6. SDK 用授权码交换 access_token(`POST /token`)
|
|
37
|
+
7. 令牌持久化到 `~/.lessie/oauth.json`
|
|
38
|
+
|
|
39
|
+
**后续连接时:**
|
|
40
|
+
|
|
41
|
+
- 自动读取本地缓存的令牌,无需再次登录
|
|
42
|
+
- 令牌过期时 SDK 自动触发 re-auth
|
|
43
|
+
|
|
44
|
+
### 模块结构
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
src/
|
|
48
|
+
├── config.ts 环境变量与静态配置(REMOTE_MCP_URL)
|
|
49
|
+
├── auth.ts OAuth 鉴权:OAuthClientProvider(Authorization Code + PKCE + 持久化)
|
|
50
|
+
├── remote.ts 远程 MCP 代理客户端:连接、授权重试、工具发现、工具转发
|
|
51
|
+
├── tools.ts 本地工具注册:工具元数据 + handler
|
|
52
|
+
└── index.ts 入口:创建 Server、注册路由(合并本地/远程工具)、启动
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**依赖方向**(单向,无循环):
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
config ← auth ← tools
|
|
59
|
+
↑ ↑
|
|
60
|
+
└─ remote ┘
|
|
61
|
+
↑
|
|
62
|
+
index(汇总所有模块)
|
|
63
|
+
```
|
|
64
|
+
|
|
5
65
|
## 前置条件
|
|
6
66
|
|
|
7
67
|
- [Claude Desktop](https://claude.ai/download)
|
|
8
68
|
- [Node.js](https://nodejs.org) 18+
|
|
9
|
-
- Lessie API Key(登录 Lessie → 设置 → 开发者 → API Keys)
|
|
10
69
|
|
|
11
70
|
## 安装
|
|
12
71
|
|
|
@@ -17,24 +76,29 @@
|
|
|
17
76
|
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
|
18
77
|
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
|
|
19
78
|
|
|
20
|
-
|
|
79
|
+
添加以下配置:
|
|
21
80
|
|
|
22
81
|
```json
|
|
23
82
|
{
|
|
24
83
|
"mcpServers": {
|
|
25
84
|
"lessie": {
|
|
26
85
|
"command": "npx",
|
|
27
|
-
"args": ["-y", "lessie-
|
|
86
|
+
"args": ["-y", "@lessie/mcp-server"],
|
|
28
87
|
"env": {
|
|
29
|
-
"
|
|
30
|
-
"SAAS_API_KEY": "sk-live-v1-你的Key"
|
|
88
|
+
"LESSIE_REMOTE_MCP_URL": "https://your-remote-mcp-server.com/mcp"
|
|
31
89
|
}
|
|
32
90
|
}
|
|
33
91
|
}
|
|
34
92
|
}
|
|
35
93
|
```
|
|
36
94
|
|
|
37
|
-
保存后**完全退出并重新打开** Claude Desktop
|
|
95
|
+
保存后**完全退出并重新打开** Claude Desktop。首次使用时会自动打开浏览器引导登录。
|
|
96
|
+
|
|
97
|
+
## 环境变量
|
|
98
|
+
|
|
99
|
+
| 变量 | 必填 | 说明 |
|
|
100
|
+
| ---------------------- | ---- | -------------------- |
|
|
101
|
+
| `LESSIE_REMOTE_MCP_URL`| 否 | 远程 MCP Server 地址 |
|
|
38
102
|
|
|
39
103
|
## 验证
|
|
40
104
|
|
|
@@ -42,16 +106,13 @@
|
|
|
42
106
|
|
|
43
107
|
> 查看我的 Lessie 账号信息
|
|
44
108
|
|
|
45
|
-
## 可用工具
|
|
46
|
-
|
|
47
|
-
| 工具 | 说明 |
|
|
48
|
-
| ----------------- | ---------------------------------------- |
|
|
49
|
-
| `get_account_info` | 查看当前账号详情(用户名、邮箱、状态、角色等) |
|
|
50
|
-
|
|
51
109
|
## 常见问题
|
|
52
110
|
|
|
53
111
|
**工具列表中没有 lessie?**
|
|
54
112
|
检查配置文件 JSON 格式是否正确,然后完全退出并重启 Claude Desktop。
|
|
55
113
|
|
|
56
|
-
|
|
57
|
-
|
|
114
|
+
**连接远程服务器失败?**
|
|
115
|
+
确认 `LESSIE_REMOTE_MCP_URL` 地址正确,且远程服务器已实现 OAuth 2.1 端点。
|
|
116
|
+
|
|
117
|
+
**如何重新登录?**
|
|
118
|
+
删除 `~/.lessie/oauth.json` 后重启 Claude Desktop,会重新打开浏览器授权。
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lessie-mcp
|
|
3
|
+
description: >-
|
|
4
|
+
Lessie MCP 代理网关:OAuth 鉴权、远程工具代理。
|
|
5
|
+
当需要理解本项目结构、鉴权机制或使用 Lessie 工具时使用。
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Lessie MCP
|
|
9
|
+
|
|
10
|
+
Lessie MCP 是一个代理网关,连接远程 Lessie 服务并暴露其工具。使用前需要完成 OAuth 授权。
|
|
11
|
+
|
|
12
|
+
## 快速开始
|
|
13
|
+
|
|
14
|
+
### 1. 授权连接
|
|
15
|
+
|
|
16
|
+
首次使用时,调用 `authorize` 工具。如果需要登录,它会返回一个授权链接:
|
|
17
|
+
|
|
18
|
+
1. 调用 `authorize`
|
|
19
|
+
2. 将返回的授权链接展示给用户,请用户在浏览器中打开
|
|
20
|
+
3. 用户在浏览器中完成登录授权
|
|
21
|
+
4. 授权完成后,远程工具自动可用,无需再次调用 `authorize`
|
|
22
|
+
|
|
23
|
+
已授权时调用 `authorize` 会直接返回当前连接状态和可用工具数量。
|
|
24
|
+
|
|
25
|
+
### 2. 使用远程工具
|
|
26
|
+
|
|
27
|
+
授权完成后,远程工具会自动出现在工具列表中,直接调用即可。
|
|
28
|
+
|
|
29
|
+
如果调用远程工具时收到"请先使用 authorize 工具"的错误提示,说明授权已过期,需要重新执行上述授权流程。
|
|
30
|
+
|
|
31
|
+
## 本地工具
|
|
32
|
+
|
|
33
|
+
| 工具 | 说明 |
|
|
34
|
+
| ------------------ | ------------------------------------------------------------------ |
|
|
35
|
+
| `authorize` | 发起授权或检查连接状态。未授权时返回授权链接,已连接时返回状态信息 |
|
|
36
|
+
| `get_access_token` | 获取当前 OAuth access token 及剩余有效时间 |
|
|
37
|
+
|
|
38
|
+
## 项目架构(开发参考)
|
|
39
|
+
|
|
40
|
+
### 鉴权:OAuth Authorization Code + PKCE
|
|
41
|
+
|
|
42
|
+
`src/auth.ts` 实现 `OAuthClientProvider` 接口:
|
|
43
|
+
|
|
44
|
+
- 令牌和客户端信息持久化到 `~/.lessie/oauth.json`
|
|
45
|
+
- `redirectToAuthorization(url)` 存储授权 URL(不自动打开浏览器)
|
|
46
|
+
- `waitForCallback()` 在 `127.0.0.1:19836` 监听回调,等待用户完成授权
|
|
47
|
+
|
|
48
|
+
### 连接策略
|
|
49
|
+
|
|
50
|
+
`src/remote.ts` 管理远程连接:
|
|
51
|
+
|
|
52
|
+
- `connectToRemote()` — 启动时静默尝试(用缓存 token),未授权不阻塞
|
|
53
|
+
- `initiateAuth()` — 发起 OAuth 流程,返回授权 URL,后台等待回调自动完成连接
|
|
54
|
+
- `callRemoteTool()` — 授权进行中自动等待;未连接时返回错误引导用户调用 `authorize`
|
|
55
|
+
|
|
56
|
+
传输层优先使用 Streamable HTTP,不可用时自动降级到 SSE。
|
|
57
|
+
|
|
58
|
+
### 环境变量
|
|
59
|
+
|
|
60
|
+
| 变量 | 必填 | 说明 |
|
|
61
|
+
| ----------------------- | ---- | -------------------- |
|
|
62
|
+
| `LESSIE_REMOTE_MCP_URL` | 否 | 远程 MCP Server 地址 |
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 鉴权(Authorization Code + PKCE + 动态客户端注册)。
|
|
3
|
+
*
|
|
4
|
+
* 实现 MCP SDK 的 OAuthClientProvider 接口:
|
|
5
|
+
* 1. 首次连接时通过 RFC 7591 动态注册客户端(POST /register)
|
|
6
|
+
* 2. 打开浏览器引导用户到 SaaS 页面登录并授权
|
|
7
|
+
* 3. 在 localhost 临时 HTTP 服务器上接收授权码回调
|
|
8
|
+
* 4. SDK 自动用授权码交换 access_token(POST /token)
|
|
9
|
+
*
|
|
10
|
+
* 客户端信息和令牌持久化到 ~/.lessie/oauth.json,进程重启后复用,
|
|
11
|
+
* 避免每次都打开浏览器。
|
|
12
|
+
*/
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { OAUTH_SERVER_URL, OAUTH_AUTHORIZATION_ENDPOINT, OAUTH_TOKEN_ENDPOINT, OAUTH_REGISTRATION_ENDPOINT, REMOTE_MCP_URL, } from "./config.js";
|
|
18
|
+
const CALLBACK_PORT = 19836;
|
|
19
|
+
const REDIRECT_URL = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
|
|
20
|
+
const STORAGE_DIR = join(homedir(), ".lessie");
|
|
21
|
+
const STORAGE_FILE = join(STORAGE_DIR, "oauth.json");
|
|
22
|
+
const CALLBACK_TIMEOUT_MS = 120_000;
|
|
23
|
+
// JSON 损坏时返回空对象,用户需重新授权(损坏数据无法恢复)
|
|
24
|
+
function loadStorage() {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(STORAGE_FILE, "utf-8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function persistStorage(data) {
|
|
33
|
+
mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 });
|
|
34
|
+
writeFileSync(STORAGE_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
35
|
+
// writeFileSync mode 仅在创建新文件时生效;已存在文件需 chmod 确保权限正确
|
|
36
|
+
chmodSync(STORAGE_FILE, 0o600);
|
|
37
|
+
}
|
|
38
|
+
export class LessieAuthProvider {
|
|
39
|
+
_storage;
|
|
40
|
+
_codeVerifier = "";
|
|
41
|
+
_callbackPromise = null;
|
|
42
|
+
_httpServer = null;
|
|
43
|
+
_envDiscoveryState;
|
|
44
|
+
/** 等待用户访问的授权 URL;授权完成后清除 */
|
|
45
|
+
pendingAuthUrl = null;
|
|
46
|
+
constructor() {
|
|
47
|
+
this._storage = loadStorage();
|
|
48
|
+
this._envDiscoveryState = buildEnvDiscoveryState();
|
|
49
|
+
}
|
|
50
|
+
get redirectUrl() {
|
|
51
|
+
return REDIRECT_URL;
|
|
52
|
+
}
|
|
53
|
+
get clientMetadata() {
|
|
54
|
+
return {
|
|
55
|
+
redirect_uris: [REDIRECT_URL],
|
|
56
|
+
grant_types: ["authorization_code"],
|
|
57
|
+
response_types: ["code"],
|
|
58
|
+
client_name: "lessie-mcp",
|
|
59
|
+
token_endpoint_auth_method: "client_secret_basic",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
clientInformation() {
|
|
63
|
+
return this._storage.clientInfo;
|
|
64
|
+
}
|
|
65
|
+
async saveClientInformation(info) {
|
|
66
|
+
this._storage.clientInfo = info;
|
|
67
|
+
persistStorage(this._storage);
|
|
68
|
+
}
|
|
69
|
+
async tokens() {
|
|
70
|
+
return this._storage.tokens;
|
|
71
|
+
}
|
|
72
|
+
async saveTokens(tokens) {
|
|
73
|
+
this._storage.tokens = tokens;
|
|
74
|
+
this._storage.tokensSavedAt = Date.now();
|
|
75
|
+
persistStorage(this._storage);
|
|
76
|
+
}
|
|
77
|
+
/** 当前 access_token 的剩余有效秒数(基于本地时钟估算) */
|
|
78
|
+
remainingSeconds() {
|
|
79
|
+
if (!this._storage.tokens?.expires_in || !this._storage.tokensSavedAt)
|
|
80
|
+
return 0;
|
|
81
|
+
const elapsed = (Date.now() - this._storage.tokensSavedAt) / 1000;
|
|
82
|
+
return Math.max(0, Math.floor(this._storage.tokens.expires_in - elapsed));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* SDK 在需要用户授权时调用此方法。
|
|
86
|
+
* 启动 localhost 临时 HTTP 服务器接收回调,然后打开浏览器。
|
|
87
|
+
*/
|
|
88
|
+
async redirectToAuthorization(url) {
|
|
89
|
+
if (this._httpServer) {
|
|
90
|
+
this._httpServer.close();
|
|
91
|
+
this._httpServer = null;
|
|
92
|
+
}
|
|
93
|
+
this._callbackPromise = new Promise((resolve, reject) => {
|
|
94
|
+
let timer;
|
|
95
|
+
const cleanup = () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
this._httpServer = null;
|
|
98
|
+
setTimeout(() => server.close(), 500);
|
|
99
|
+
};
|
|
100
|
+
const server = createServer((req, res) => {
|
|
101
|
+
const reqUrl = new URL(req.url, `http://127.0.0.1:${CALLBACK_PORT}`);
|
|
102
|
+
if (reqUrl.pathname !== "/callback") {
|
|
103
|
+
res.writeHead(404);
|
|
104
|
+
res.end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const code = reqUrl.searchParams.get("code");
|
|
108
|
+
if (code) {
|
|
109
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
110
|
+
res.end("<!DOCTYPE html><html><body>" +
|
|
111
|
+
"<h1>授权成功</h1><p>可以关闭此页面,回到 Claude Desktop 继续使用。</p>" +
|
|
112
|
+
"</body></html>");
|
|
113
|
+
cleanup();
|
|
114
|
+
resolve(code);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const error = reqUrl.searchParams.get("error") ?? "unknown_error";
|
|
118
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
119
|
+
res.end(`<!DOCTYPE html><html><body><h1>授权失败</h1><p>${escapeHtml(error)}</p></body></html>`);
|
|
120
|
+
cleanup();
|
|
121
|
+
reject(new Error(`Authorization denied: ${error}`));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
server.on("error", (err) => {
|
|
125
|
+
this._httpServer = null;
|
|
126
|
+
const msg = err.code === "EADDRINUSE"
|
|
127
|
+
? `Port ${CALLBACK_PORT} is already in use`
|
|
128
|
+
: `Callback server error: ${err.message}`;
|
|
129
|
+
reject(new Error(msg));
|
|
130
|
+
});
|
|
131
|
+
server.listen(CALLBACK_PORT, "127.0.0.1");
|
|
132
|
+
this._httpServer = server;
|
|
133
|
+
timer = setTimeout(() => {
|
|
134
|
+
this._httpServer = null;
|
|
135
|
+
server.close();
|
|
136
|
+
reject(new Error("Authorization timed out — no callback received within 2 minutes"));
|
|
137
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
138
|
+
});
|
|
139
|
+
this.pendingAuthUrl = url.toString();
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 等待用户在浏览器中完成授权后回调,返回授权码。
|
|
143
|
+
* 必须在 redirectToAuthorization 之后调用。
|
|
144
|
+
*/
|
|
145
|
+
async waitForCallback() {
|
|
146
|
+
if (!this._callbackPromise)
|
|
147
|
+
throw new Error("No pending authorization flow");
|
|
148
|
+
return this._callbackPromise;
|
|
149
|
+
}
|
|
150
|
+
async saveCodeVerifier(codeVerifier) {
|
|
151
|
+
this._codeVerifier = codeVerifier;
|
|
152
|
+
}
|
|
153
|
+
async codeVerifier() {
|
|
154
|
+
return this._codeVerifier;
|
|
155
|
+
}
|
|
156
|
+
async saveDiscoveryState(state) {
|
|
157
|
+
this._storage.discoveryState = state;
|
|
158
|
+
persistStorage(this._storage);
|
|
159
|
+
}
|
|
160
|
+
discoveryState() {
|
|
161
|
+
return this._envDiscoveryState ?? this._storage.discoveryState;
|
|
162
|
+
}
|
|
163
|
+
async invalidateCredentials(scope) {
|
|
164
|
+
if (scope === "all" || scope === "client") {
|
|
165
|
+
delete this._storage.clientInfo;
|
|
166
|
+
}
|
|
167
|
+
if (scope === "all" || scope === "tokens") {
|
|
168
|
+
delete this._storage.tokens;
|
|
169
|
+
delete this._storage.tokensSavedAt;
|
|
170
|
+
}
|
|
171
|
+
if (scope === "all" || scope === "verifier") {
|
|
172
|
+
this._codeVerifier = "";
|
|
173
|
+
}
|
|
174
|
+
if (scope === "all" || scope === "discovery") {
|
|
175
|
+
delete this._storage.discoveryState;
|
|
176
|
+
}
|
|
177
|
+
persistStorage(this._storage);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function escapeHtml(s) {
|
|
181
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* 从环境变量构建 OAuthDiscoveryState。
|
|
185
|
+
* 至少需要配置 authorization_endpoint 和 token_endpoint 才会生效;
|
|
186
|
+
* 未配置则返回 undefined,由 SDK 自动发现。
|
|
187
|
+
*/
|
|
188
|
+
function buildEnvDiscoveryState() {
|
|
189
|
+
if (!OAUTH_AUTHORIZATION_ENDPOINT || !OAUTH_TOKEN_ENDPOINT)
|
|
190
|
+
return undefined;
|
|
191
|
+
const authorizationServerUrl = OAUTH_SERVER_URL || REMOTE_MCP_URL;
|
|
192
|
+
if (!authorizationServerUrl)
|
|
193
|
+
return undefined;
|
|
194
|
+
return {
|
|
195
|
+
authorizationServerUrl,
|
|
196
|
+
authorizationServerMetadata: {
|
|
197
|
+
issuer: authorizationServerUrl,
|
|
198
|
+
authorization_endpoint: OAUTH_AUTHORIZATION_ENDPOINT,
|
|
199
|
+
token_endpoint: OAUTH_TOKEN_ENDPOINT,
|
|
200
|
+
registration_endpoint: OAUTH_REGISTRATION_ENDPOINT,
|
|
201
|
+
response_types_supported: ["code"],
|
|
202
|
+
grant_types_supported: ["authorization_code"],
|
|
203
|
+
code_challenge_methods_supported: ["S256"],
|
|
204
|
+
token_endpoint_auth_methods_supported: [
|
|
205
|
+
"client_secret_basic",
|
|
206
|
+
"client_secret_post",
|
|
207
|
+
"none",
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
resourceMetadata: {
|
|
211
|
+
resource: REMOTE_MCP_URL,
|
|
212
|
+
authorization_servers: [authorizationServerUrl],
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** 全局单例,供 remote / tools 共享 */
|
|
217
|
+
export const authProvider = new LessieAuthProvider();
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 环境变量与静态配置。
|
|
3
|
+
*
|
|
4
|
+
* 所有外部可配置项在此集中读取,其余模块通过 import 获取,
|
|
5
|
+
* 避免散落的 process.env 访问。
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
export const pkg = require("../package.json");
|
|
11
|
+
/** 远程 MCP Server 地址 */
|
|
12
|
+
export const REMOTE_MCP_URL = process.env.LESSIE_REMOTE_MCP_URL || "https://s6.jennie.im/mcp-server/mcp";
|
|
13
|
+
/**
|
|
14
|
+
* OAuth 端点配置(可选)。
|
|
15
|
+
* 未设置时 SDK 通过 RFC 8414 / RFC 9728 自动发现。
|
|
16
|
+
*/
|
|
17
|
+
export const OAUTH_SERVER_URL = process.env.LESSIE_OAUTH_SERVER_URL || "https://s1.jennie.im/sit-api/oauth";
|
|
18
|
+
export const OAUTH_AUTHORIZATION_ENDPOINT = process.env.LESSIE_OAUTH_AUTHORIZATION_ENDPOINT || "https://s1.jennie.im/sit-api/oauth/authorize";
|
|
19
|
+
export const OAUTH_TOKEN_ENDPOINT = process.env.LESSIE_OAUTH_TOKEN_ENDPOINT || "https://s1.jennie.im/sit-api/oauth/token";
|
|
20
|
+
export const OAUTH_REGISTRATION_ENDPOINT = process.env.LESSIE_OAUTH_REGISTRATION_ENDPOINT || "https://s1.jennie.im/sit-api/oauth/register";
|
|
21
|
+
/** 读取 SKILL.md 作为 MCP instructions,跳过 YAML front-matter */
|
|
22
|
+
export function loadInstructions() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = readFileSync(new URL("../SKILL.md", import.meta.url), "utf-8");
|
|
25
|
+
return raw.replace(/^---[\s\S]*?---\n*/, "").trim() || undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,96 +1,71 @@
|
|
|
1
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 { createRequire } from "node:module";
|
|
5
|
-
const require = createRequire(import.meta.url);
|
|
6
|
-
const pkg = require("../package.json");
|
|
7
|
-
// ── 环境变量 ──────────────────────────────────────────────────────────────────
|
|
8
|
-
const BASE_URL = process.env.LESSIE_BASE_URL || 'https://www.lessie.ai/prod-api';
|
|
9
|
-
const API_KEY = process.env.LESSIE_API_KEY;
|
|
10
|
-
if (!BASE_URL) {
|
|
11
|
-
console.error("Error: LESSIE_BASE_URL is not set");
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
14
|
-
if (!API_KEY) {
|
|
15
|
-
console.error("Error: LESSIE_API_KEY is not set");
|
|
16
|
-
process.exit(1);
|
|
17
|
-
}
|
|
18
|
-
let jwtCache = null;
|
|
19
2
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
3
|
+
* 入口:创建 MCP Server,注册请求路由,启动 stdio 传输。
|
|
4
|
+
*
|
|
5
|
+
* 使用低级 Server 而非 McpServer,以便手动控制 tools/list 和 tools/call 路由,
|
|
6
|
+
* 将本地工具与远程代理工具合并到同一个工具列表中返回。
|
|
7
|
+
*
|
|
8
|
+
* 工具路由策略:
|
|
9
|
+
* tools/list → 合并本地工具 + 远程工具
|
|
10
|
+
* tools/call → 先匹配本地 handler,未命中则转发到远程 MCP Server
|
|
22
11
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
const body = (await res.json());
|
|
38
|
-
if (!body.data?.token) {
|
|
39
|
-
throw new Error(`/auth/token response missing data.token. Got: ${JSON.stringify(body)}`);
|
|
40
|
-
}
|
|
41
|
-
jwtCache = {
|
|
42
|
-
token: body.data.token,
|
|
43
|
-
expiry: now + body.data.expiresIn * 1000,
|
|
44
|
-
};
|
|
45
|
-
return jwtCache.token;
|
|
46
|
-
}
|
|
47
|
-
async function api(method, path, body) {
|
|
48
|
-
const token = await getJwt();
|
|
49
|
-
const res = await fetch(`${BASE_URL}${path}`, {
|
|
50
|
-
method,
|
|
51
|
-
headers: {
|
|
52
|
-
"Authorization": token,
|
|
53
|
-
"Content-Type": "application/json",
|
|
54
|
-
},
|
|
55
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
56
|
-
});
|
|
57
|
-
if (!res.ok) {
|
|
58
|
-
const text = await res.text().catch(() => "");
|
|
59
|
-
throw new Error(`API error ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
|
|
60
|
-
}
|
|
61
|
-
if (res.status === 204)
|
|
62
|
-
return undefined;
|
|
63
|
-
const wrapper = (await res.json());
|
|
64
|
-
return wrapper.data;
|
|
65
|
-
}
|
|
66
|
-
// ── 工具结果辅助函数 ───────────────────────────────────────────────────────────
|
|
67
|
-
function textResult(data) {
|
|
68
|
-
return {
|
|
69
|
-
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
73
|
-
const server = new McpServer({
|
|
74
|
-
name: "lessie-mcp",
|
|
75
|
-
version: pkg.version,
|
|
12
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
15
|
+
import { REMOTE_MCP_URL, pkg, loadInstructions } from "./config.js";
|
|
16
|
+
import { connectToRemote, listRemoteTools, callRemoteTool, isRemoteConnected, setOnAuthComplete } from "./remote.js";
|
|
17
|
+
import { LOCAL_TOOLS, localHandlers } from "./tools.js";
|
|
18
|
+
const server = new Server({ name: "lessie-mcp", version: pkg.version }, {
|
|
19
|
+
capabilities: { tools: {} },
|
|
20
|
+
instructions: loadInstructions(),
|
|
21
|
+
});
|
|
22
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
23
|
+
const remoteTools = await listRemoteTools();
|
|
24
|
+
return { tools: [...LOCAL_TOOLS, ...remoteTools] };
|
|
76
25
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return textResult(data);
|
|
26
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
27
|
+
const { name, arguments: args } = request.params;
|
|
28
|
+
const handler = localHandlers.get(name);
|
|
29
|
+
if (handler)
|
|
30
|
+
return handler(args ?? {});
|
|
31
|
+
return callRemoteTool(name, args);
|
|
84
32
|
});
|
|
85
33
|
// ── 启动 ──────────────────────────────────────────────────────────────────────
|
|
86
34
|
const transport = new StdioServerTransport();
|
|
87
35
|
await server.connect(transport);
|
|
88
|
-
|
|
89
|
-
server.sendLoggingMessage({
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
36
|
+
setOnAuthComplete((toolCount) => {
|
|
37
|
+
server.sendLoggingMessage({
|
|
38
|
+
level: "info",
|
|
39
|
+
logger: "lessie",
|
|
40
|
+
data: `Authorization successful. Connected to remote MCP server. Discovered ${toolCount} tools.`,
|
|
41
|
+
});
|
|
42
|
+
server.sendToolListChanged();
|
|
93
43
|
});
|
|
44
|
+
try {
|
|
45
|
+
await connectToRemote();
|
|
46
|
+
if (isRemoteConnected()) {
|
|
47
|
+
const remoteTools = await listRemoteTools();
|
|
48
|
+
server.sendLoggingMessage({
|
|
49
|
+
level: "info",
|
|
50
|
+
logger: "lessie",
|
|
51
|
+
data: `Connected to remote MCP server (${REMOTE_MCP_URL}). Discovered ${remoteTools.length} tools.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else if (REMOTE_MCP_URL) {
|
|
55
|
+
server.sendLoggingMessage({
|
|
56
|
+
level: "warning",
|
|
57
|
+
logger: "lessie",
|
|
58
|
+
data: "Remote MCP server requires authorization. Use the 'authorize' tool to connect.",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
server.sendLoggingMessage({
|
|
64
|
+
level: "error",
|
|
65
|
+
logger: "lessie",
|
|
66
|
+
data: `Failed to connect to remote MCP server: ${err}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
94
69
|
server.sendLoggingMessage({
|
|
95
70
|
level: "info",
|
|
96
71
|
logger: "lessie",
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
/** 注册授权完成回调(用于发送 logging 通知) */
|
|
41
|
+
export function setOnAuthComplete(cb) {
|
|
42
|
+
onAuthComplete = cb;
|
|
43
|
+
}
|
|
44
|
+
export function isRemoteConnected() {
|
|
45
|
+
return client !== null;
|
|
46
|
+
}
|
|
47
|
+
/** 启动时静默连接:有缓存令牌才尝试,无令牌或连接失败则跳过 */
|
|
48
|
+
export async function connectToRemote() {
|
|
49
|
+
if (!REMOTE_MCP_URL)
|
|
50
|
+
return;
|
|
51
|
+
if (!(await authProvider.tokens()))
|
|
52
|
+
return;
|
|
53
|
+
const url = new URL(REMOTE_MCP_URL);
|
|
54
|
+
try {
|
|
55
|
+
client = await tryConnect(url);
|
|
56
|
+
const { tools } = await client.listTools();
|
|
57
|
+
cachedTools = tools;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
client = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 发起 OAuth 授权流程。
|
|
65
|
+
* - 若已连接,返回 { connected: true }
|
|
66
|
+
* - 若有缓存令牌未连接,尝试连接
|
|
67
|
+
* - 若需要授权,直接调用 SDK auth() 获取授权 URL,不经过 transport
|
|
68
|
+
*/
|
|
69
|
+
export async function initiateAuth() {
|
|
70
|
+
if (client)
|
|
71
|
+
return { connected: true };
|
|
72
|
+
if (!REMOTE_MCP_URL)
|
|
73
|
+
throw new Error("LESSIE_REMOTE_MCP_URL is not configured");
|
|
74
|
+
// 复用已有的待完成授权流程
|
|
75
|
+
if (authProvider.pendingAuthUrl) {
|
|
76
|
+
ensureAuthCompletion();
|
|
77
|
+
return { authUrl: authProvider.pendingAuthUrl };
|
|
78
|
+
}
|
|
79
|
+
const serverUrl = REMOTE_MCP_URL;
|
|
80
|
+
// 直接调用 SDK auth():发现元数据 → 注册客户端 → 生成 PKCE → redirectToAuthorization
|
|
81
|
+
const result = await auth(authProvider, { serverUrl, fetchFn });
|
|
82
|
+
if (result === "AUTHORIZED") {
|
|
83
|
+
const url = new URL(serverUrl);
|
|
84
|
+
client = await tryConnect(url);
|
|
85
|
+
const { tools } = await client.listTools();
|
|
86
|
+
cachedTools = tools;
|
|
87
|
+
return { connected: true };
|
|
88
|
+
}
|
|
89
|
+
// 'REDIRECT' — 用户需要访问授权 URL
|
|
90
|
+
const authUrl = authProvider.pendingAuthUrl;
|
|
91
|
+
if (!authUrl)
|
|
92
|
+
throw new Error("OAuth flow initiated but no authorization URL was generated");
|
|
93
|
+
ensureAuthCompletion();
|
|
94
|
+
return { authUrl };
|
|
95
|
+
}
|
|
96
|
+
/** 启动后台任务:等待用户浏览器回调 → 交换令牌 → 连接远程服务器 */
|
|
97
|
+
function ensureAuthCompletion() {
|
|
98
|
+
if (authCompletion)
|
|
99
|
+
return;
|
|
100
|
+
const serverUrl = REMOTE_MCP_URL;
|
|
101
|
+
const url = new URL(serverUrl);
|
|
102
|
+
authCompletion = (async () => {
|
|
103
|
+
try {
|
|
104
|
+
const code = await authProvider.waitForCallback();
|
|
105
|
+
await auth(authProvider, { serverUrl, authorizationCode: code, fetchFn });
|
|
106
|
+
client = await tryConnect(url);
|
|
107
|
+
const { tools } = await client.listTools();
|
|
108
|
+
cachedTools = tools;
|
|
109
|
+
authProvider.pendingAuthUrl = null;
|
|
110
|
+
onAuthComplete?.(cachedTools.length);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
authProvider.pendingAuthUrl = null;
|
|
114
|
+
console.error("[lessie] Authorization completion failed:", e);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
authCompletion = null;
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
}
|
|
121
|
+
async function tryConnect(url) {
|
|
122
|
+
const c = new Client({ name: "lessie-mcp-proxy", version: pkg.version });
|
|
123
|
+
await c.connect(new StreamableHTTPClientTransport(url, { authProvider, fetch: fetchFn }));
|
|
124
|
+
return c;
|
|
125
|
+
}
|
|
126
|
+
/** 获取远程工具列表;连接可用时实时刷新,否则返回缓存 */
|
|
127
|
+
export async function listRemoteTools() {
|
|
128
|
+
if (!client)
|
|
129
|
+
return cachedTools;
|
|
130
|
+
try {
|
|
131
|
+
const { tools } = await client.listTools();
|
|
132
|
+
cachedTools = tools;
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.warn("[lessie] Failed to refresh remote tools, using cache:", String(err));
|
|
136
|
+
}
|
|
137
|
+
return cachedTools;
|
|
138
|
+
}
|
|
139
|
+
/** 转发工具调用到远程 MCP Server;失败时引导用户重新授权 */
|
|
140
|
+
export async function callRemoteTool(name, args) {
|
|
141
|
+
if (authCompletion)
|
|
142
|
+
await authCompletion;
|
|
143
|
+
if (!client) {
|
|
144
|
+
return {
|
|
145
|
+
content: [
|
|
146
|
+
{
|
|
147
|
+
type: "text",
|
|
148
|
+
text: `无法调用工具 ${name}:远程 MCP 服务器未连接。请先使用 authorize 工具完成登录授权。`,
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
isError: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return (await client.callTool({ name, arguments: args }));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
await reconnect();
|
|
159
|
+
if (!client) {
|
|
160
|
+
return {
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: "text",
|
|
164
|
+
text: `工具 ${name} 调用失败,请使用 authorize 工具重新登录。`,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
isError: true,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return (await client.callTool({ name, arguments: args }));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 重连,关闭旧连接;并发调用共享同一个 Promise 避免竞争。
|
|
175
|
+
* 静默尝试连接,若令牌已失效则 client 保持 null。
|
|
176
|
+
*/
|
|
177
|
+
async function reconnect() {
|
|
178
|
+
if (reconnecting)
|
|
179
|
+
return reconnecting;
|
|
180
|
+
reconnecting = (async () => {
|
|
181
|
+
try {
|
|
182
|
+
const old = client;
|
|
183
|
+
client = null;
|
|
184
|
+
await old?.close().catch(() => { });
|
|
185
|
+
await connectToRemote();
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
reconnecting = null;
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
191
|
+
return reconnecting;
|
|
192
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地工具注册。
|
|
3
|
+
*
|
|
4
|
+
* 定义不依赖远程 MCP Server 的工具(如鉴权辅助工具)。
|
|
5
|
+
* 新增工具时在 LOCAL_TOOLS 中声明元数据,在 localHandlers 中注册处理函数。
|
|
6
|
+
*/
|
|
7
|
+
import { authProvider } from "./auth.js";
|
|
8
|
+
import { initiateAuth, listRemoteTools } from "./remote.js";
|
|
9
|
+
export const LOCAL_TOOLS = [
|
|
10
|
+
{
|
|
11
|
+
name: "authorize",
|
|
12
|
+
description: "连接到远程 Lessie 服务。首次使用或授权过期时返回授权链接,用户需在浏览器中打开完成登录。已连接时返回当前状态。",
|
|
13
|
+
inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "get_access_token",
|
|
17
|
+
description: "获取当前有效的 OAuth access token,可用于直接调用 Lessie API。返回 token 字符串和剩余有效时间。",
|
|
18
|
+
inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
export const localHandlers = new Map();
|
|
22
|
+
localHandlers.set("authorize", async () => {
|
|
23
|
+
const result = await initiateAuth();
|
|
24
|
+
if ("connected" in result) {
|
|
25
|
+
const tools = await listRemoteTools();
|
|
26
|
+
return {
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: "text",
|
|
30
|
+
text: `已连接到远程 MCP 服务器,共发现 ${tools.length} 个远程工具,无需重新授权。`,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
content: [
|
|
37
|
+
{
|
|
38
|
+
type: "text",
|
|
39
|
+
text: [
|
|
40
|
+
"需要授权登录。请在浏览器中打开以下链接完成授权:",
|
|
41
|
+
"",
|
|
42
|
+
result.authUrl,
|
|
43
|
+
"",
|
|
44
|
+
"授权完成后,远程工具将自动可用。",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
localHandlers.set("get_access_token", async () => {
|
|
51
|
+
const tokens = await authProvider.tokens();
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: "text",
|
|
56
|
+
text: JSON.stringify({
|
|
57
|
+
access_token: tokens?.access_token,
|
|
58
|
+
token_type: tokens?.token_type,
|
|
59
|
+
remainingSeconds: authProvider.remainingSeconds(),
|
|
60
|
+
}, null, 2),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
});
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# Lessie MCP OAuth 2.1 接入指南
|
|
2
|
+
|
|
3
|
+
本文档面向 MCP Client 开发者,描述如何通过 OAuth 2.1 授权流程获取 access_token 并调用 Lessie MCP 接口。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 授权流程概览
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
MCP Client Lessie 授权服务器
|
|
11
|
+
│ │
|
|
12
|
+
│ ① POST /mcp (无 token) │
|
|
13
|
+
│──────────────────────────────────────────────►│
|
|
14
|
+
│◄──────────────── 401 Unauthorized ────────────│
|
|
15
|
+
│ │
|
|
16
|
+
│ ② GET /.well-known/oauth-authorization-server │
|
|
17
|
+
│──────────────────────────────────────────────►│
|
|
18
|
+
│◄──── 元数据 JSON (各端点地址) ─────────────────│
|
|
19
|
+
│ │
|
|
20
|
+
│ ③ POST /register (动态客户端注册) │
|
|
21
|
+
│──────────────────────────────────────────────►│
|
|
22
|
+
│◄──── { client_id, client_secret } ───────────│
|
|
23
|
+
│ │
|
|
24
|
+
│ ④ 打开浏览器 → /authorize?... │
|
|
25
|
+
│ 用户登录 → 点击"授权" │
|
|
26
|
+
│◄──── 302 → callback?code=xxx&state=yyy ───────│
|
|
27
|
+
│ │
|
|
28
|
+
│ ⑤ POST /token (code + code_verifier) │
|
|
29
|
+
│──────────────────────────────────────────────►│
|
|
30
|
+
│◄──── { access_token, token_type, expires_in } │
|
|
31
|
+
│ │
|
|
32
|
+
│ ⑥ POST /mcp (Authorization: Bearer xxx) │
|
|
33
|
+
│──────────────────────────────────────────────►│
|
|
34
|
+
│◄──── 业务数据 ────────────────────────────────│
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> 本服务实现了 OAuth 2.1 + PKCE (S256) 授权码流程,遵循 RFC 8414、RFC 7591、RFC 7636 标准。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 基础地址
|
|
42
|
+
|
|
43
|
+
| 环境 | Base URL |
|
|
44
|
+
|------|----------|
|
|
45
|
+
| 生产 | `https://www.lessie.ai/prod-api` |
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 1. 授权服务器元数据发现
|
|
50
|
+
|
|
51
|
+
> RFC 8414
|
|
52
|
+
|
|
53
|
+
**请求**
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
GET /.well-known/oauth-authorization-server
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**响应 200**
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"issuer": "https://www.lessie.ai/prod-api",
|
|
64
|
+
"authorization_endpoint": "https://www.lessie.ai/prod-api/authorize",
|
|
65
|
+
"token_endpoint": "https://www.lessie.ai/prod-api/token",
|
|
66
|
+
"registration_endpoint": "https://www.lessie.ai/prod-api/register",
|
|
67
|
+
"response_types_supported": ["code"],
|
|
68
|
+
"grant_types_supported": ["authorization_code"],
|
|
69
|
+
"code_challenge_methods_supported": ["S256"],
|
|
70
|
+
"token_endpoint_auth_methods_supported": [
|
|
71
|
+
"client_secret_basic",
|
|
72
|
+
"client_secret_post",
|
|
73
|
+
"none"
|
|
74
|
+
],
|
|
75
|
+
"scopes_supported": ["read", "write"]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
客户端应通过此端点**动态发现**各接口地址,不要硬编码。
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 2. 动态客户端注册
|
|
84
|
+
|
|
85
|
+
> RFC 7591 — 首次连接时调用,无需用户操作
|
|
86
|
+
|
|
87
|
+
**请求**
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
POST /register
|
|
91
|
+
Content-Type: application/json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"redirect_uris": ["http://127.0.0.1:3000/callback"],
|
|
97
|
+
"grant_types": ["authorization_code"],
|
|
98
|
+
"response_types": ["code"],
|
|
99
|
+
"client_name": "My MCP Client",
|
|
100
|
+
"token_endpoint_auth_method": "client_secret_basic"
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
105
|
+
|------|------|------|------|
|
|
106
|
+
| `redirect_uris` | string[] | 是 | 回调地址列表,仅允许 loopback(`127.0.0.1` / `localhost` / `[::1]`)或 `https` |
|
|
107
|
+
| `grant_types` | string[] | 否 | 默认 `["authorization_code"]` |
|
|
108
|
+
| `response_types` | string[] | 否 | 默认 `["code"]` |
|
|
109
|
+
| `client_name` | string | 否 | 默认 `"MCP Client"`,显示在授权页面上 |
|
|
110
|
+
| `token_endpoint_auth_method` | string | 否 | `"client_secret_basic"`(默认)、`"client_secret_post"` 或 `"none"`(public client) |
|
|
111
|
+
|
|
112
|
+
**响应 201 Created**
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"client_id": "lessie-aBcDeFgHiJkLmNoP",
|
|
117
|
+
"client_secret": "sk-xXxXxXxXxXxXxXxX...",
|
|
118
|
+
"client_name": "My MCP Client",
|
|
119
|
+
"redirect_uris": ["http://127.0.0.1:3000/callback"],
|
|
120
|
+
"grant_types": ["authorization_code"]
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> **重要**:`client_secret` 仅在注册响应中返回一次,请安全存储。后续无法再次获取。
|
|
125
|
+
|
|
126
|
+
**错误响应**
|
|
127
|
+
|
|
128
|
+
| HTTP 状态码 | error | 说明 |
|
|
129
|
+
|-------------|-------|------|
|
|
130
|
+
| 400 | `invalid_redirect_uri` | redirect_uri 格式不合法(必须是 loopback 或 https) |
|
|
131
|
+
| 403 | `client_limit_exceeded` | 客户端注册数量已达上限 |
|
|
132
|
+
| 429 | `too_many_requests` | 注册频率超限,请稍后重试 |
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 3. 授权请求
|
|
137
|
+
|
|
138
|
+
客户端在本地生成 PKCE 参数后,打开浏览器跳转到授权页面。
|
|
139
|
+
|
|
140
|
+
### 3.1 生成 PKCE 参数
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
code_verifier = 随机生成 43-128 字符的 URL-safe 字符串
|
|
144
|
+
code_challenge = BASE64URL(SHA256(code_verifier))
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 3.2 打开浏览器
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
GET /authorize
|
|
151
|
+
?response_type=code
|
|
152
|
+
&client_id={client_id}
|
|
153
|
+
&redirect_uri={redirect_uri}
|
|
154
|
+
&code_challenge={code_challenge}
|
|
155
|
+
&code_challenge_method=S256
|
|
156
|
+
&state={随机字符串}
|
|
157
|
+
&scope=read write
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
| 参数 | 必填 | 说明 |
|
|
161
|
+
|------|------|------|
|
|
162
|
+
| `response_type` | 是 | 固定为 `code` |
|
|
163
|
+
| `client_id` | 是 | 注册时获取的 client_id |
|
|
164
|
+
| `redirect_uri` | 是 | 必须与注册时提交的地址匹配(loopback 忽略端口) |
|
|
165
|
+
| `code_challenge` | 是 | PKCE challenge (S256) |
|
|
166
|
+
| `code_challenge_method` | 是 | 固定为 `S256` |
|
|
167
|
+
| `state` | 建议 | 防 CSRF 的随机字符串,会原样回传 |
|
|
168
|
+
| `scope` | 否 | 空格分隔,可选值:`read`、`write`。默认 `read write` |
|
|
169
|
+
|
|
170
|
+
### 3.3 用户操作
|
|
171
|
+
|
|
172
|
+
1. 用户在浏览器中登录 Lessie 账号(如未登录会自动跳转登录页)
|
|
173
|
+
2. 用户在授权页面看到客户端名称,点击"授权"
|
|
174
|
+
|
|
175
|
+
### 3.4 授权回调
|
|
176
|
+
|
|
177
|
+
授权成功后,浏览器 302 重定向到 `redirect_uri`:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
http://127.0.0.1:3000/callback?code=xxxx&state=yyyy
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
| 参数 | 说明 |
|
|
184
|
+
|------|------|
|
|
185
|
+
| `code` | 授权码,10 分钟有效,一次性使用 |
|
|
186
|
+
| `state` | 与请求时一致,客户端应验证其值 |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 4. 令牌交换
|
|
191
|
+
|
|
192
|
+
用授权码 + PKCE code_verifier 换取 access_token。
|
|
193
|
+
|
|
194
|
+
**请求**
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
POST /token
|
|
198
|
+
Content-Type: application/x-www-form-urlencoded
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### 4.1 客户端认证方式
|
|
202
|
+
|
|
203
|
+
**方式 A — Basic Auth(推荐)**
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
Authorization: Basic base64(client_id:client_secret)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
表单参数:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
grant_type=authorization_code
|
|
213
|
+
&code={授权码}
|
|
214
|
+
&redirect_uri={与授权请求一致的 redirect_uri}
|
|
215
|
+
&code_verifier={PKCE code_verifier}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**方式 B — Form Body**
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
grant_type=authorization_code
|
|
222
|
+
&code={授权码}
|
|
223
|
+
&redirect_uri={与授权请求一致的 redirect_uri}
|
|
224
|
+
&code_verifier={PKCE code_verifier}
|
|
225
|
+
&client_id={client_id}
|
|
226
|
+
&client_secret={client_secret}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**方式 C — Public Client(无 secret)**
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
grant_type=authorization_code
|
|
233
|
+
&code={授权码}
|
|
234
|
+
&redirect_uri={与授权请求一致的 redirect_uri}
|
|
235
|
+
&code_verifier={PKCE code_verifier}
|
|
236
|
+
&client_id={client_id}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**响应 200**
|
|
240
|
+
|
|
241
|
+
```json
|
|
242
|
+
{
|
|
243
|
+
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
|
244
|
+
"token_type": "bearer",
|
|
245
|
+
"expires_in": 3600,
|
|
246
|
+
"scope": "read write"
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
| 字段 | 说明 |
|
|
251
|
+
|------|------|
|
|
252
|
+
| `access_token` | JWT 格式的访问令牌 |
|
|
253
|
+
| `token_type` | 固定为 `bearer` |
|
|
254
|
+
| `expires_in` | 有效期(秒),默认 3600(1 小时) |
|
|
255
|
+
| `scope` | 实际授予的权限范围,空格分隔 |
|
|
256
|
+
|
|
257
|
+
**错误响应**
|
|
258
|
+
|
|
259
|
+
| HTTP 状态码 | error | 说明 |
|
|
260
|
+
|-------------|-------|------|
|
|
261
|
+
| 400 | `unsupported_grant_type` | 仅支持 `authorization_code` |
|
|
262
|
+
| 400 | `invalid_request` | 缺少必要参数(code / redirect_uri / code_verifier) |
|
|
263
|
+
| 400 | `invalid_grant` | 授权码无效、已过期、已使用,或 PKCE 验证失败 |
|
|
264
|
+
| 401 | `invalid_client` | 客户端认证失败(client_id 不存在或 secret 错误) |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## 5. 调用 MCP 接口
|
|
269
|
+
|
|
270
|
+
在每次请求中携带 Bearer Token:
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
POST /mcp
|
|
274
|
+
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
|
275
|
+
Content-Type: application/json
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### 401 响应处理
|
|
279
|
+
|
|
280
|
+
| WWW-Authenticate 值 | 含义 | 处理方式 |
|
|
281
|
+
|---------------------|------|----------|
|
|
282
|
+
| `Bearer` | 未携带 token | 触发完整 OAuth 授权流程 |
|
|
283
|
+
| `Bearer error="invalid_token"` | token 无效或已过期 | 重新走授权流程获取新 token |
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 6. 可用 Scope
|
|
288
|
+
|
|
289
|
+
| Scope | 说明 |
|
|
290
|
+
|-------|------|
|
|
291
|
+
| `read` | 读取数据 |
|
|
292
|
+
| `write` | 写入数据 |
|
|
293
|
+
|
|
294
|
+
默认授予 `read write`。
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## 7. 完整接入示例(伪代码)
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
# 1. 发现元数据
|
|
302
|
+
metadata = GET("/.well-known/oauth-authorization-server")
|
|
303
|
+
|
|
304
|
+
# 2. 动态注册(仅首次)
|
|
305
|
+
registration = POST(metadata.registration_endpoint, {
|
|
306
|
+
"redirect_uris": ["http://127.0.0.1:9876/callback"],
|
|
307
|
+
"grant_types": ["authorization_code"],
|
|
308
|
+
"client_name": "My Agent"
|
|
309
|
+
})
|
|
310
|
+
client_id = registration.client_id
|
|
311
|
+
client_secret = registration.client_secret
|
|
312
|
+
|
|
313
|
+
# 3. 生成 PKCE
|
|
314
|
+
code_verifier = random_url_safe_string(64)
|
|
315
|
+
code_challenge = base64url(sha256(code_verifier))
|
|
316
|
+
|
|
317
|
+
# 4. 打开浏览器授权
|
|
318
|
+
open_browser(f"{metadata.authorization_endpoint}"
|
|
319
|
+
f"?response_type=code"
|
|
320
|
+
f"&client_id={client_id}"
|
|
321
|
+
f"&redirect_uri=http://127.0.0.1:9876/callback"
|
|
322
|
+
f"&code_challenge={code_challenge}"
|
|
323
|
+
f"&code_challenge_method=S256"
|
|
324
|
+
f"&state={random_state}")
|
|
325
|
+
|
|
326
|
+
# 5. 本地 HTTP 服务器等待回调,获取 code
|
|
327
|
+
code = wait_for_callback()
|
|
328
|
+
|
|
329
|
+
# 6. 用 code 换 token
|
|
330
|
+
token_response = POST(metadata.token_endpoint, {
|
|
331
|
+
"grant_type": "authorization_code",
|
|
332
|
+
"code": code,
|
|
333
|
+
"redirect_uri": "http://127.0.0.1:9876/callback",
|
|
334
|
+
"code_verifier": code_verifier,
|
|
335
|
+
"client_id": client_id,
|
|
336
|
+
"client_secret": client_secret
|
|
337
|
+
})
|
|
338
|
+
access_token = token_response.access_token
|
|
339
|
+
|
|
340
|
+
# 7. 调用 MCP
|
|
341
|
+
response = POST("/mcp", headers={"Authorization": f"Bearer {access_token}"}, ...)
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## 8. 注意事项
|
|
347
|
+
|
|
348
|
+
- `redirect_uri` 仅允许 loopback 地址(`127.0.0.1` / `localhost` / `[::1]`,任意端口)或 `https` 地址
|
|
349
|
+
- loopback 回调时端口可以与注册时不同(符合 [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3))
|
|
350
|
+
- `client_secret` 仅注册时返回一次,请妥善保管
|
|
351
|
+
- 授权码 10 分钟有效,且只能使用一次
|
|
352
|
+
- access_token 有效期 1 小时,过期后需重新走授权流程
|
|
353
|
+
- PKCE (S256) 为强制要求,不支持 plain 方式
|
|
354
|
+
- `state` 参数建议始终使用,用于防止 CSRF 攻击
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessie/mcp-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc",
|
|
11
11
|
"dev": "tsc --watch",
|
|
12
|
-
"start": "node dist/index.js"
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
15
16
|
"@modelcontextprotocol/sdk": "^1.10.0",
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: add-mcp-tool
|
|
3
|
-
description: >-
|
|
4
|
-
为 Lessie MCP Server 添加新的 MCP 工具。当用户要求新增 MCP tool、暴露新的 API
|
|
5
|
-
端点、或为 AI Agent 添加新功能时使用。
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# 为 Lessie MCP Server 添加工具
|
|
9
|
-
|
|
10
|
-
## 项目基础设施
|
|
11
|
-
|
|
12
|
-
`src/index.ts` 已包含以下基础设施,添加工具时直接复用,**不要重复实现**:
|
|
13
|
-
|
|
14
|
-
- **`getJwt()`** — API Key → JWT 换取 + 内存缓存 + 到期前 60s 自动刷新
|
|
15
|
-
- **`api<T>(method, path, body?)`** — 通用请求封装,自动注入 Authorization header,非 2xx 抛错
|
|
16
|
-
- **`textResult(data)`** — 将返回数据包装为 MCP content 格式
|
|
17
|
-
|
|
18
|
-
## 添加工具流程
|
|
19
|
-
|
|
20
|
-
### 1. 确认 API 端点
|
|
21
|
-
|
|
22
|
-
从 `swagger.json` 中找到目标端点,记录:
|
|
23
|
-
|
|
24
|
-
- HTTP 方法和路径(如 `GET /agent/account/info`)
|
|
25
|
-
- 请求参数 / 请求体 schema
|
|
26
|
-
- 响应 `data` 字段的结构
|
|
27
|
-
|
|
28
|
-
### 2. 在工具定义区域添加代码
|
|
29
|
-
|
|
30
|
-
在 `src/index.ts` 的 `// ── 工具定义` 区域、`// ── 启动` 区域之前添加:
|
|
31
|
-
|
|
32
|
-
```typescript
|
|
33
|
-
server.tool(
|
|
34
|
-
"动词_名词", // 工具名:下划线分隔,动词开头
|
|
35
|
-
"一句话描述功能和用途", // Agent 靠 description 决定是否调用
|
|
36
|
-
{ // zod schema 定义参数,无参数传 {}
|
|
37
|
-
paramName: z.string().describe("参数说明"),
|
|
38
|
-
},
|
|
39
|
-
async ({ paramName }) => {
|
|
40
|
-
const data = await api("GET", `/agent/your/path?param=${paramName}`);
|
|
41
|
-
return textResult(data);
|
|
42
|
-
}
|
|
43
|
-
);
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
### 3. 命名规范
|
|
47
|
-
|
|
48
|
-
| 类型 | 前缀 | 示例 |
|
|
49
|
-
|------|------|------|
|
|
50
|
-
| 查询单个 | `get_` | `get_account_info` |
|
|
51
|
-
| 查询列表 | `list_` | `list_projects` |
|
|
52
|
-
| 创建 | `create_` | `create_task` |
|
|
53
|
-
| 更新 | `update_` | `update_task_status` |
|
|
54
|
-
| 删除 | `delete_` | `delete_project` |
|
|
55
|
-
|
|
56
|
-
### 4. Description 编写要求
|
|
57
|
-
|
|
58
|
-
- 用中文,一句话说清**做什么**
|
|
59
|
-
- Agent 完全依赖 description 决定是否调用,必须精确
|
|
60
|
-
- **写入类工具**(create / update / delete)在末尾加:`执行前请向用户确认`
|
|
61
|
-
|
|
62
|
-
好的 description:
|
|
63
|
-
```
|
|
64
|
-
查看当前 Lessie 账号的详细信息,包括用户名、邮箱、账号状态、角色、邀请码等。
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
差的 description:
|
|
68
|
-
```
|
|
69
|
-
获取账号信息
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
### 5. 参数定义
|
|
73
|
-
|
|
74
|
-
- 用 `zod` 定义,每个参数加 `.describe()` 说明用途
|
|
75
|
-
- 可选参数用 `.optional()`
|
|
76
|
-
- 无参数传空对象 `{}`
|
|
77
|
-
|
|
78
|
-
```typescript
|
|
79
|
-
{
|
|
80
|
-
projectId: z.string().describe("项目 ID"),
|
|
81
|
-
status: z.enum(["active", "archived"]).optional().describe("筛选状态"),
|
|
82
|
-
page: z.number().default(1).describe("页码"),
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
### 6. 构建验证
|
|
87
|
-
|
|
88
|
-
添加完工具后运行 `npm run build` 确认 TypeScript 编译通过。
|
|
89
|
-
|
|
90
|
-
## 完整示例
|
|
91
|
-
|
|
92
|
-
```typescript
|
|
93
|
-
// 列出项目
|
|
94
|
-
server.tool(
|
|
95
|
-
"list_projects",
|
|
96
|
-
"列出当前用户的所有项目,支持按状态筛选。返回项目名称、状态、创建时间等信息。",
|
|
97
|
-
{
|
|
98
|
-
status: z.enum(["active", "archived"]).optional().describe("按状态筛选"),
|
|
99
|
-
page: z.number().default(1).describe("页码"),
|
|
100
|
-
},
|
|
101
|
-
async ({ status, page }) => {
|
|
102
|
-
const params = new URLSearchParams({ page: String(page) });
|
|
103
|
-
if (status) params.set("status", status);
|
|
104
|
-
const data = await api("GET", `/agent/projects?${params}`);
|
|
105
|
-
return textResult(data);
|
|
106
|
-
}
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
// 创建项目(写入类,description 要求确认)
|
|
110
|
-
server.tool(
|
|
111
|
-
"create_project",
|
|
112
|
-
"创建一个新项目。执行前请向用户确认。",
|
|
113
|
-
{
|
|
114
|
-
name: z.string().describe("项目名称"),
|
|
115
|
-
description: z.string().optional().describe("项目描述"),
|
|
116
|
-
},
|
|
117
|
-
async ({ name, description }) => {
|
|
118
|
-
const data = await api("POST", "/agent/projects", { name, description });
|
|
119
|
-
return textResult(data);
|
|
120
|
-
}
|
|
121
|
-
);
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
## API 响应格式
|
|
125
|
-
|
|
126
|
-
后端统一返回格式为 `{ code: number, msg: string, data: T }`。`api()` 函数已自动解包,工具 handler 中拿到的就是 `data` 部分。
|
|
127
|
-
|
|
128
|
-
## 注意事项
|
|
129
|
-
|
|
130
|
-
- 所有路径以 `/agent/` 开头(Agent 专用端点)
|
|
131
|
-
- `api()` 已处理 token 注入和错误抛出,handler 中只需关注业务逻辑
|
|
132
|
-
- 不要在工具 handler 中直接使用 `fetch`,统一走 `api()` 封装
|