apifm-admin-mcp 26.5.1 → 26.5.3
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 +16 -11
- package/package.json +2 -2
- package/src/browser-auth.js +549 -84
- package/src/index.js +144 -54
- package/src/self-check.js +32 -1
package/README.md
CHANGED
|
@@ -8,9 +8,11 @@ It is designed for Kiro, Cursor, Claude Code, Codex, Windsurf, Trae, Qoder, and
|
|
|
8
8
|
|
|
9
9
|
Secrets must not be pasted into model chat.
|
|
10
10
|
|
|
11
|
-
This MCP exposes `apifm_admin_start_auth`, which returns a one-time localhost URL. The user enters Basic Authentication credentials, an existing `X-Token`,
|
|
11
|
+
This MCP exposes `apifm_admin_start_auth`, which returns a one-time localhost URL. The user enters Basic Authentication credentials, an existing `X-Token`, username/password login details, email/password login details, or email signup details in that local browser page. Those secrets stay in the local MCP process memory and are never part of MCP tool arguments.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
If an API call is attempted before authorization, `apifm_admin_call` and `apifm_admin_find_and_call` return the authorization URL directly in both visible text and structured content as `authUrl`. The agent should show that URL immediately instead of asking the user to call another tool.
|
|
14
|
+
|
|
15
|
+
The API callers reject payloads and headers containing sensitive field names such as `pwd`, `password`, `token`, `x-token`, `authorization`, `secret`, or `key`.
|
|
14
16
|
|
|
15
17
|
Accounts are in-memory only. Restarting the MCP server clears all tokens and credentials.
|
|
16
18
|
|
|
@@ -51,21 +53,24 @@ If installed globally:
|
|
|
51
53
|
|
|
52
54
|
## Tools
|
|
53
55
|
|
|
54
|
-
- `apifm_admin_start_auth`: Starts a local secure authorization page for Basic Auth, X-Token,
|
|
56
|
+
- `apifm_admin_start_auth`: Starts a local secure authorization page for username login, email login, Basic Auth, X-Token, or email registration. The page supports Simplified Chinese, Traditional Chinese, and English.
|
|
55
57
|
- `apifm_admin_accounts`: Lists local account aliases without revealing secrets.
|
|
56
58
|
- `apifm_admin_switch_account`: Switches the active account alias.
|
|
57
59
|
- `apifm_admin_remove_account`: Clears an account alias and its in-memory secrets.
|
|
58
|
-
- `
|
|
59
|
-
- `
|
|
60
|
-
- `
|
|
60
|
+
- `apifm_admin_find_and_call`: Searches for the best matching SDK method, calls it, and returns the live backend response in `apiResult`.
|
|
61
|
+
- `apifm_admin_call`: Calls an exact SDK method using the selected local account and returns the live backend response in `apiResult`.
|
|
62
|
+
- `apifm_admin_search_methods`: Planning helper only. Searches methods dynamically from the installed `apifm-admin` SDK, but does not call the API.
|
|
63
|
+
- `apifm_admin_method_info`: Planning helper only. Shows route, method, parameters, and usage for one SDK method, but does not call the API.
|
|
64
|
+
|
|
65
|
+
For real backend data, agents should call `apifm_admin_find_and_call` or `apifm_admin_call`. The final API response is returned under `apiResult`.
|
|
61
66
|
|
|
62
67
|
## Example Agent Flow
|
|
63
68
|
|
|
64
|
-
1. User:
|
|
65
|
-
2. Agent calls `
|
|
66
|
-
3.
|
|
67
|
-
4.
|
|
68
|
-
5. Agent
|
|
69
|
+
1. User: "Log in to my admin account and read the user list."
|
|
70
|
+
2. Agent calls `apifm_admin_find_and_call` with `query: "user list"` and a non-sensitive payload such as `{ "page": 1, "pageSize": 20 }`.
|
|
71
|
+
3. If not authorized yet, the tool returns `authUrl` immediately. The user opens that URL and chooses username login, email login, Basic Auth, X-Token, or email signup.
|
|
72
|
+
4. User completes authorization in the local browser page.
|
|
73
|
+
5. Agent retries the same API call and answers from the returned `apiResult`, not from method documentation.
|
|
69
74
|
|
|
70
75
|
## Local Check
|
|
71
76
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apifm-admin-mcp",
|
|
3
|
-
"version": "26.5.
|
|
3
|
+
"version": "26.5.3",
|
|
4
4
|
"description": "MCP server for safely operating APIFM admin APIs through the apifm-admin SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"apifm-admin-mcp": "
|
|
7
|
+
"apifm-admin-mcp": "src/index.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"src",
|
package/src/browser-auth.js
CHANGED
|
@@ -6,7 +6,164 @@ import { getSdk, resetSdkConfig } from './sdk.js'
|
|
|
6
6
|
|
|
7
7
|
const pendingSessions = new Map()
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const I18N = {
|
|
10
|
+
'zh-CN': {
|
|
11
|
+
brand: 'APIFM Admin MCP',
|
|
12
|
+
title: '安全连接你的 APIFM 后台',
|
|
13
|
+
lead: '在本机页面完成授权,密钥只保存在当前 MCP 进程内存,不会进入聊天上下文。',
|
|
14
|
+
trustLocal: '本地 127.0.0.1 页面提交',
|
|
15
|
+
trustMemory: '凭证仅在内存中保存',
|
|
16
|
+
trustSwitch: '支持多个后台账号切换',
|
|
17
|
+
footer: '授权完成后回到 Agent,重新执行刚才的后台操作。',
|
|
18
|
+
formTitle: '选择授权方式',
|
|
19
|
+
expires: '页面过期时间',
|
|
20
|
+
alias: '账号别名',
|
|
21
|
+
aliasPh: 'default-admin',
|
|
22
|
+
methodUsername: '用户名登录',
|
|
23
|
+
methodUsernameSub: '用户名 + 密码',
|
|
24
|
+
methodEmail: '邮箱登录',
|
|
25
|
+
methodEmailSub: '邮箱 + 密码',
|
|
26
|
+
methodBasic: 'Basic Auth',
|
|
27
|
+
methodBasicSub: '用户名 + 密码',
|
|
28
|
+
methodToken: 'X-Token',
|
|
29
|
+
methodTokenSub: '直接使用令牌',
|
|
30
|
+
methodRegister: '邮箱注册',
|
|
31
|
+
methodRegisterSub: '开通新后台',
|
|
32
|
+
usernameHint: '使用后台用户名和密码登录,MCP 会通过 SDK 换取 X-Token。',
|
|
33
|
+
emailHint: '使用后台邮箱和密码登录,MCP 会通过 SDK 换取 X-Token。',
|
|
34
|
+
basicHint: '填写 Basic Authentication 信息,将作为 Authorization 请求头调用后台接口。',
|
|
35
|
+
tokenHint: '直接填写管理员登录后的 X-Token,用于后续所有后台接口调用。',
|
|
36
|
+
registerHint: '使用邮箱注册开通新后台账号。需要验证码时,请先通过 SDK 获取邮箱验证码。',
|
|
37
|
+
username: '用户名',
|
|
38
|
+
usernamePh: '请输入后台用户名',
|
|
39
|
+
password: '密码',
|
|
40
|
+
passwordPh: '请输入登录密码',
|
|
41
|
+
pdomain: '专属域名,可选',
|
|
42
|
+
pdomainPh: '例如 yourdomain',
|
|
43
|
+
email: '邮箱地址',
|
|
44
|
+
emailPh: 'name@example.com',
|
|
45
|
+
basicUser: 'Basic 用户名',
|
|
46
|
+
basicUserPh: '请输入 Basic 用户名',
|
|
47
|
+
basicPass: 'Basic 密码',
|
|
48
|
+
basicPassPh: '请输入 Basic 密码',
|
|
49
|
+
xToken: 'X-Token',
|
|
50
|
+
xTokenPh: '粘贴管理员 X-Token',
|
|
51
|
+
newPassword: '登录密码',
|
|
52
|
+
newPasswordPh: '设置登录密码',
|
|
53
|
+
name: '姓名或昵称',
|
|
54
|
+
namePh: '可选',
|
|
55
|
+
mailCode: '邮箱验证码',
|
|
56
|
+
mailCodePh: '请输入验证码',
|
|
57
|
+
submit: '完成授权',
|
|
58
|
+
security: '请不要把密码、Token 或 Basic Authentication 内容粘贴到聊天窗口。',
|
|
59
|
+
closeNote: '提交成功后可以关闭本页面。'
|
|
60
|
+
},
|
|
61
|
+
'zh-TW': {
|
|
62
|
+
brand: 'APIFM Admin MCP',
|
|
63
|
+
title: '安全連接你的 APIFM 後台',
|
|
64
|
+
lead: '在本機頁面完成授權,密鑰只保存在目前 MCP 程序記憶體,不會進入聊天上下文。',
|
|
65
|
+
trustLocal: '本地 127.0.0.1 頁面提交',
|
|
66
|
+
trustMemory: '憑證僅在記憶體中保存',
|
|
67
|
+
trustSwitch: '支援多個後台帳號切換',
|
|
68
|
+
footer: '授權完成後回到 Agent,重新執行剛才的後台操作。',
|
|
69
|
+
formTitle: '選擇授權方式',
|
|
70
|
+
expires: '頁面過期時間',
|
|
71
|
+
alias: '帳號別名',
|
|
72
|
+
aliasPh: 'default-admin',
|
|
73
|
+
methodUsername: '使用者名稱登入',
|
|
74
|
+
methodUsernameSub: '使用者名稱 + 密碼',
|
|
75
|
+
methodEmail: '郵箱登入',
|
|
76
|
+
methodEmailSub: '郵箱 + 密碼',
|
|
77
|
+
methodBasic: 'Basic Auth',
|
|
78
|
+
methodBasicSub: '使用者名稱 + 密碼',
|
|
79
|
+
methodToken: 'X-Token',
|
|
80
|
+
methodTokenSub: '直接使用令牌',
|
|
81
|
+
methodRegister: '郵箱註冊',
|
|
82
|
+
methodRegisterSub: '開通新後台',
|
|
83
|
+
usernameHint: '使用後台使用者名稱和密碼登入,MCP 會透過 SDK 換取 X-Token。',
|
|
84
|
+
emailHint: '使用後台郵箱和密碼登入,MCP 會透過 SDK 換取 X-Token。',
|
|
85
|
+
basicHint: '填寫 Basic Authentication 資訊,將作為 Authorization 請求頭呼叫後台介面。',
|
|
86
|
+
tokenHint: '直接填寫管理員登入後的 X-Token,用於後續所有後台介面呼叫。',
|
|
87
|
+
registerHint: '使用郵箱註冊開通新後台帳號。需要驗證碼時,請先透過 SDK 取得郵箱驗證碼。',
|
|
88
|
+
username: '使用者名稱',
|
|
89
|
+
usernamePh: '請輸入後台使用者名稱',
|
|
90
|
+
password: '密碼',
|
|
91
|
+
passwordPh: '請輸入登入密碼',
|
|
92
|
+
pdomain: '專屬網域,可選',
|
|
93
|
+
pdomainPh: '例如 yourdomain',
|
|
94
|
+
email: '郵箱地址',
|
|
95
|
+
emailPh: 'name@example.com',
|
|
96
|
+
basicUser: 'Basic 使用者名稱',
|
|
97
|
+
basicUserPh: '請輸入 Basic 使用者名稱',
|
|
98
|
+
basicPass: 'Basic 密碼',
|
|
99
|
+
basicPassPh: '請輸入 Basic 密碼',
|
|
100
|
+
xToken: 'X-Token',
|
|
101
|
+
xTokenPh: '貼上管理員 X-Token',
|
|
102
|
+
newPassword: '登入密碼',
|
|
103
|
+
newPasswordPh: '設定登入密碼',
|
|
104
|
+
name: '姓名或暱稱',
|
|
105
|
+
namePh: '可選',
|
|
106
|
+
mailCode: '郵箱驗證碼',
|
|
107
|
+
mailCodePh: '請輸入驗證碼',
|
|
108
|
+
submit: '完成授權',
|
|
109
|
+
security: '請不要把密碼、Token 或 Basic Authentication 內容貼到聊天視窗。',
|
|
110
|
+
closeNote: '提交成功後可以關閉本頁面。'
|
|
111
|
+
},
|
|
112
|
+
en: {
|
|
113
|
+
brand: 'APIFM Admin MCP',
|
|
114
|
+
title: 'Connect your APIFM admin safely',
|
|
115
|
+
lead: 'Authorize on this local page. Secrets stay in this MCP process memory and never enter the chat transcript.',
|
|
116
|
+
trustLocal: 'Submitted to local 127.0.0.1 only',
|
|
117
|
+
trustMemory: 'Credentials are memory-only',
|
|
118
|
+
trustSwitch: 'Multiple admin accounts supported',
|
|
119
|
+
footer: 'After authorization, return to the agent and run the backend action again.',
|
|
120
|
+
formTitle: 'Choose an authorization method',
|
|
121
|
+
expires: 'Page expires at',
|
|
122
|
+
alias: 'Account alias',
|
|
123
|
+
aliasPh: 'default-admin',
|
|
124
|
+
methodUsername: 'Username login',
|
|
125
|
+
methodUsernameSub: 'Username + password',
|
|
126
|
+
methodEmail: 'Email login',
|
|
127
|
+
methodEmailSub: 'Email + password',
|
|
128
|
+
methodBasic: 'Basic Auth',
|
|
129
|
+
methodBasicSub: 'Username + password',
|
|
130
|
+
methodToken: 'X-Token',
|
|
131
|
+
methodTokenSub: 'Use an existing token',
|
|
132
|
+
methodRegister: 'Email signup',
|
|
133
|
+
methodRegisterSub: 'Create new admin',
|
|
134
|
+
usernameHint: 'Log in with an admin username and password. The MCP will exchange them for an X-Token through the SDK.',
|
|
135
|
+
emailHint: 'Log in with an admin email and password. The MCP will exchange them for an X-Token through the SDK.',
|
|
136
|
+
basicHint: 'Enter Basic Authentication credentials. They will be sent as the Authorization header for API calls.',
|
|
137
|
+
tokenHint: 'Enter an existing admin X-Token for later backend API calls.',
|
|
138
|
+
registerHint: 'Create a new admin account by email. If a verification code is required, request it through the SDK first.',
|
|
139
|
+
username: 'Username',
|
|
140
|
+
usernamePh: 'Enter admin username',
|
|
141
|
+
password: 'Password',
|
|
142
|
+
passwordPh: 'Enter login password',
|
|
143
|
+
pdomain: 'Private domain, optional',
|
|
144
|
+
pdomainPh: 'Example: yourdomain',
|
|
145
|
+
email: 'Email address',
|
|
146
|
+
emailPh: 'name@example.com',
|
|
147
|
+
basicUser: 'Basic username',
|
|
148
|
+
basicUserPh: 'Enter Basic username',
|
|
149
|
+
basicPass: 'Basic password',
|
|
150
|
+
basicPassPh: 'Enter Basic password',
|
|
151
|
+
xToken: 'X-Token',
|
|
152
|
+
xTokenPh: 'Paste admin X-Token',
|
|
153
|
+
newPassword: 'Login password',
|
|
154
|
+
newPasswordPh: 'Set login password',
|
|
155
|
+
name: 'Name or nickname',
|
|
156
|
+
namePh: 'Optional',
|
|
157
|
+
mailCode: 'Email verification code',
|
|
158
|
+
mailCodePh: 'Enter verification code',
|
|
159
|
+
submit: 'Authorize account',
|
|
160
|
+
security: 'Do not paste passwords, tokens, or Basic Authentication values into the chat window.',
|
|
161
|
+
closeNote: 'You can close this page after authorization succeeds.'
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function createAuthSession({ authType = 'all', alias = '', domains = {}, port = 0 }) {
|
|
166
|
+
const initialAuthType = normalizeAuthType(authType)
|
|
10
167
|
const sessionId = crypto.randomUUID()
|
|
11
168
|
const createdAt = Date.now()
|
|
12
169
|
const expiresAt = createdAt + 10 * 60 * 1000
|
|
@@ -21,7 +178,7 @@ export async function createAuthSession({ authType, alias = '', domains = {}, po
|
|
|
21
178
|
}
|
|
22
179
|
|
|
23
180
|
if (req.method === 'GET') {
|
|
24
|
-
sendHtml(res, renderForm({ authType, alias,
|
|
181
|
+
sendHtml(res, renderForm({ authType: initialAuthType, alias, expiresAt }))
|
|
25
182
|
return
|
|
26
183
|
}
|
|
27
184
|
|
|
@@ -31,7 +188,7 @@ export async function createAuthSession({ authType, alias = '', domains = {}, po
|
|
|
31
188
|
}
|
|
32
189
|
|
|
33
190
|
const fields = await readForm(req)
|
|
34
|
-
const account = await finishAuth({ authType, alias, domains, fields })
|
|
191
|
+
const account = await finishAuth({ authType: fields.authType || initialAuthType, alias, domains, fields })
|
|
35
192
|
pendingSessions.delete(sessionId)
|
|
36
193
|
sendHtml(res, renderSuccess(account))
|
|
37
194
|
setTimeout(() => server.close(), 250)
|
|
@@ -46,7 +203,7 @@ export async function createAuthSession({ authType, alias = '', domains = {}, po
|
|
|
46
203
|
|
|
47
204
|
const address = server.address()
|
|
48
205
|
const url = `http://127.0.0.1:${address.port}/${sessionId}`
|
|
49
|
-
pendingSessions.set(sessionId, { server, authType, alias, createdAt, expiresAt })
|
|
206
|
+
pendingSessions.set(sessionId, { server, authType: initialAuthType, alias, createdAt, expiresAt })
|
|
50
207
|
setTimeout(() => {
|
|
51
208
|
const pending = pendingSessions.get(sessionId)
|
|
52
209
|
if (pending) {
|
|
@@ -58,6 +215,7 @@ export async function createAuthSession({ authType, alias = '', domains = {}, po
|
|
|
58
215
|
return {
|
|
59
216
|
url,
|
|
60
217
|
sessionId,
|
|
218
|
+
authType: initialAuthType,
|
|
61
219
|
expiresAt: new Date(expiresAt).toISOString(),
|
|
62
220
|
message:
|
|
63
221
|
'Open this local URL in a browser and enter credentials there. Do not paste secrets into the chat.'
|
|
@@ -65,38 +223,39 @@ export async function createAuthSession({ authType, alias = '', domains = {}, po
|
|
|
65
223
|
}
|
|
66
224
|
|
|
67
225
|
async function finishAuth({ authType, alias, domains, fields }) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
226
|
+
const selectedAuthType = normalizeAuthType(authType)
|
|
227
|
+
if (selectedAuthType === 'basic') {
|
|
228
|
+
const username = required(fields.basicUsername, 'Basic username')
|
|
229
|
+
const password = required(fields.basicPassword, 'Basic password')
|
|
71
230
|
return upsertAccount({
|
|
72
231
|
alias: fields.alias || alias,
|
|
73
|
-
authType,
|
|
232
|
+
authType: 'basic',
|
|
74
233
|
basicAuth: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
|
75
234
|
domains
|
|
76
235
|
})
|
|
77
236
|
}
|
|
78
237
|
|
|
79
|
-
if (
|
|
238
|
+
if (selectedAuthType === 'x-token') {
|
|
80
239
|
return upsertAccount({
|
|
81
240
|
alias: fields.alias || alias,
|
|
82
|
-
authType,
|
|
83
|
-
token: required(fields.
|
|
241
|
+
authType: selectedAuthType,
|
|
242
|
+
token: required(fields.xToken, 'X-Token'),
|
|
84
243
|
domains
|
|
85
244
|
})
|
|
86
245
|
}
|
|
87
246
|
|
|
88
|
-
if (
|
|
89
|
-
const loginMode =
|
|
247
|
+
if (selectedAuthType === 'username-password' || selectedAuthType === 'email-password' || selectedAuthType === 'password') {
|
|
248
|
+
const loginMode = selectedAuthType === 'email-password' ? 'email' : 'username'
|
|
90
249
|
const loginPayload =
|
|
91
250
|
loginMode === 'email'
|
|
92
251
|
? {
|
|
93
|
-
email: required(fields.
|
|
94
|
-
pwd: required(fields.
|
|
252
|
+
email: required(fields.loginEmail, 'email'),
|
|
253
|
+
pwd: required(fields.loginPassword, 'password'),
|
|
95
254
|
rememberMe: true
|
|
96
255
|
}
|
|
97
256
|
: {
|
|
98
|
-
userName: required(fields.
|
|
99
|
-
pwd: required(fields.
|
|
257
|
+
userName: required(fields.loginUsername, 'username'),
|
|
258
|
+
pwd: required(fields.loginPassword, 'password'),
|
|
100
259
|
rememberMe: true,
|
|
101
260
|
pdomain: fields.pdomain || undefined
|
|
102
261
|
}
|
|
@@ -117,28 +276,28 @@ async function finishAuth({ authType, alias, domains, fields }) {
|
|
|
117
276
|
}
|
|
118
277
|
return upsertAccount({
|
|
119
278
|
alias: fields.alias || alias,
|
|
120
|
-
authType,
|
|
279
|
+
authType: selectedAuthType,
|
|
121
280
|
token,
|
|
122
281
|
domains
|
|
123
282
|
})
|
|
124
283
|
}
|
|
125
284
|
|
|
126
|
-
if (
|
|
285
|
+
if (selectedAuthType === 'register-email') {
|
|
127
286
|
const sdk = getSdk()
|
|
128
287
|
resetSdkConfig()
|
|
129
288
|
if (domains && Object.keys(domains).length) {
|
|
130
289
|
sdk.setDomains(domains)
|
|
131
290
|
}
|
|
132
291
|
const result = await sdk.registerAdminSaveEmail({
|
|
133
|
-
email: required(fields.
|
|
134
|
-
pwd: required(fields.
|
|
135
|
-
name: fields.
|
|
292
|
+
email: required(fields.registerEmail, 'email'),
|
|
293
|
+
pwd: required(fields.registerPassword, 'password'),
|
|
294
|
+
name: fields.registerName || undefined,
|
|
136
295
|
mailCode: fields.mailCode || fields.code || undefined
|
|
137
296
|
})
|
|
138
297
|
const token = extractToken(result)
|
|
139
298
|
return upsertAccount({
|
|
140
299
|
alias: fields.alias || alias,
|
|
141
|
-
authType,
|
|
300
|
+
authType: selectedAuthType,
|
|
142
301
|
token,
|
|
143
302
|
domains
|
|
144
303
|
})
|
|
@@ -147,6 +306,16 @@ async function finishAuth({ authType, alias, domains, fields }) {
|
|
|
147
306
|
throw new Error(`Unsupported auth type: ${authType}`)
|
|
148
307
|
}
|
|
149
308
|
|
|
309
|
+
function normalizeAuthType(authType) {
|
|
310
|
+
if (authType === 'password') return 'username-password'
|
|
311
|
+
if (
|
|
312
|
+
['all', 'basic', 'x-token', 'username-password', 'email-password', 'register-email'].includes(authType)
|
|
313
|
+
) {
|
|
314
|
+
return authType
|
|
315
|
+
}
|
|
316
|
+
return 'all'
|
|
317
|
+
}
|
|
318
|
+
|
|
150
319
|
function extractToken(response) {
|
|
151
320
|
const candidates = [
|
|
152
321
|
response?.data?.token,
|
|
@@ -203,7 +372,7 @@ function escapeHtml(value) {
|
|
|
203
372
|
}
|
|
204
373
|
|
|
205
374
|
function renderForm({ authType, alias, expiresAt }) {
|
|
206
|
-
const
|
|
375
|
+
const initialType = authType === 'all' ? 'username-password' : authType
|
|
207
376
|
return `<!doctype html>
|
|
208
377
|
<html lang="zh-CN">
|
|
209
378
|
<head>
|
|
@@ -211,77 +380,373 @@ function renderForm({ authType, alias, expiresAt }) {
|
|
|
211
380
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
212
381
|
<title>APIFM Admin MCP Authorization</title>
|
|
213
382
|
<style>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
383
|
+
:root {
|
|
384
|
+
color-scheme: light;
|
|
385
|
+
--bg: oklch(0.965 0.006 250);
|
|
386
|
+
--surface: oklch(1 0 0);
|
|
387
|
+
--surface-2: oklch(0.982 0.006 250);
|
|
388
|
+
--ink: oklch(0.215 0.028 255);
|
|
389
|
+
--muted: oklch(0.43 0.028 255);
|
|
390
|
+
--line: oklch(0.875 0.013 250);
|
|
391
|
+
--accent: oklch(0.56 0.19 255);
|
|
392
|
+
--accent-ink: oklch(1 0 0);
|
|
393
|
+
--accent-soft: oklch(0.94 0.035 255);
|
|
394
|
+
--success: oklch(0.58 0.15 155);
|
|
395
|
+
--danger: oklch(0.55 0.18 25);
|
|
396
|
+
--radius: 12px;
|
|
397
|
+
}
|
|
398
|
+
* { box-sizing: border-box; }
|
|
399
|
+
body {
|
|
400
|
+
margin: 0;
|
|
401
|
+
min-height: 100vh;
|
|
402
|
+
font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
403
|
+
background:
|
|
404
|
+
radial-gradient(circle at top left, oklch(0.9 0.06 255), transparent 34rem),
|
|
405
|
+
linear-gradient(135deg, var(--bg), oklch(0.955 0.011 230));
|
|
406
|
+
color: var(--ink);
|
|
407
|
+
}
|
|
408
|
+
.shell {
|
|
409
|
+
width: min(1080px, calc(100vw - 32px));
|
|
410
|
+
min-height: 100vh;
|
|
411
|
+
margin: 0 auto;
|
|
412
|
+
display: grid;
|
|
413
|
+
place-items: center;
|
|
414
|
+
padding: 28px 0;
|
|
415
|
+
}
|
|
416
|
+
.panel {
|
|
417
|
+
width: 100%;
|
|
418
|
+
display: grid;
|
|
419
|
+
grid-template-columns: minmax(280px, 0.84fr) minmax(320px, 1.16fr);
|
|
420
|
+
background: var(--surface);
|
|
421
|
+
border: 1px solid var(--line);
|
|
422
|
+
border-radius: 16px;
|
|
423
|
+
overflow: hidden;
|
|
424
|
+
box-shadow: 0 8px 28px oklch(0.25 0.02 255 / 0.12);
|
|
425
|
+
}
|
|
426
|
+
.aside {
|
|
427
|
+
padding: 34px;
|
|
428
|
+
background: linear-gradient(180deg, oklch(0.26 0.07 255), oklch(0.19 0.04 255));
|
|
429
|
+
color: white;
|
|
430
|
+
display: flex;
|
|
431
|
+
flex-direction: column;
|
|
432
|
+
justify-content: space-between;
|
|
433
|
+
gap: 32px;
|
|
434
|
+
}
|
|
435
|
+
.brand { display: flex; align-items: center; gap: 12px; font-weight: 760; }
|
|
436
|
+
.mark {
|
|
437
|
+
width: 38px;
|
|
438
|
+
height: 38px;
|
|
439
|
+
display: grid;
|
|
440
|
+
place-items: center;
|
|
441
|
+
border-radius: 10px;
|
|
442
|
+
background: oklch(0.74 0.16 210);
|
|
443
|
+
color: oklch(0.18 0.04 255);
|
|
444
|
+
font-weight: 850;
|
|
445
|
+
}
|
|
446
|
+
h1 { margin: 28px 0 12px; font-size: 2rem; line-height: 1.12; text-wrap: balance; letter-spacing: 0; }
|
|
447
|
+
.lead { margin: 0; color: oklch(0.88 0.018 250); max-width: 48ch; }
|
|
448
|
+
.trust { display: grid; gap: 10px; margin: 28px 0 0; padding: 0; list-style: none; }
|
|
449
|
+
.trust li { display: flex; gap: 10px; align-items: flex-start; color: oklch(0.91 0.015 250); }
|
|
450
|
+
.dot { width: 8px; height: 8px; margin-top: 7px; border-radius: 999px; background: oklch(0.78 0.16 155); flex: 0 0 auto; }
|
|
451
|
+
.content { padding: 30px; }
|
|
452
|
+
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 22px; }
|
|
453
|
+
.meta { color: var(--muted); font-size: 0.92rem; }
|
|
454
|
+
.lang { display: inline-grid; grid-auto-flow: column; gap: 4px; padding: 4px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; }
|
|
455
|
+
.lang button, .method button {
|
|
456
|
+
appearance: none;
|
|
457
|
+
border: 0;
|
|
458
|
+
font: inherit;
|
|
459
|
+
cursor: pointer;
|
|
460
|
+
}
|
|
461
|
+
.lang button { padding: 6px 10px; border-radius: 999px; color: var(--muted); background: transparent; }
|
|
462
|
+
.lang button[aria-pressed="true"] { background: var(--ink); color: white; }
|
|
463
|
+
.method {
|
|
464
|
+
display: grid;
|
|
465
|
+
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
466
|
+
gap: 8px;
|
|
467
|
+
margin-bottom: 22px;
|
|
468
|
+
}
|
|
469
|
+
.method button {
|
|
470
|
+
min-height: 58px;
|
|
471
|
+
padding: 10px;
|
|
472
|
+
border: 1px solid var(--line);
|
|
473
|
+
border-radius: 10px;
|
|
474
|
+
background: var(--surface-2);
|
|
475
|
+
color: var(--ink);
|
|
476
|
+
text-align: left;
|
|
477
|
+
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
|
478
|
+
}
|
|
479
|
+
.method button:hover { border-color: oklch(0.72 0.04 255); transform: translateY(-1px); }
|
|
480
|
+
.method button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
|
|
481
|
+
.method strong { display: block; font-size: 0.92rem; line-height: 1.2; }
|
|
482
|
+
.method span { display: block; margin-top: 4px; color: var(--muted); font-size: 0.78rem; line-height: 1.25; }
|
|
483
|
+
form { display: grid; gap: 18px; }
|
|
484
|
+
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
|
485
|
+
.field { display: grid; gap: 7px; }
|
|
486
|
+
label { color: var(--ink); font-weight: 660; }
|
|
487
|
+
input {
|
|
488
|
+
width: 100%;
|
|
489
|
+
min-height: 44px;
|
|
490
|
+
padding: 10px 12px;
|
|
491
|
+
border: 1px solid var(--line);
|
|
492
|
+
border-radius: 8px;
|
|
493
|
+
background: white;
|
|
494
|
+
color: var(--ink);
|
|
495
|
+
font: inherit;
|
|
496
|
+
outline: none;
|
|
497
|
+
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
498
|
+
}
|
|
499
|
+
input::placeholder { color: oklch(0.48 0.025 255); }
|
|
500
|
+
input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px oklch(0.62 0.16 255 / 0.18); }
|
|
501
|
+
.section {
|
|
502
|
+
display: none;
|
|
503
|
+
padding: 18px;
|
|
504
|
+
border: 1px solid var(--line);
|
|
505
|
+
border-radius: 12px;
|
|
506
|
+
background: var(--surface-2);
|
|
507
|
+
}
|
|
508
|
+
.section.active { display: block; }
|
|
509
|
+
.section-head { margin: 0 0 14px; color: var(--muted); }
|
|
510
|
+
.submit {
|
|
511
|
+
min-height: 46px;
|
|
512
|
+
border: 0;
|
|
513
|
+
border-radius: 8px;
|
|
514
|
+
background: var(--accent);
|
|
515
|
+
color: var(--accent-ink);
|
|
516
|
+
font: inherit;
|
|
517
|
+
font-weight: 760;
|
|
518
|
+
cursor: pointer;
|
|
519
|
+
transition: transform 160ms ease, filter 160ms ease;
|
|
520
|
+
}
|
|
521
|
+
.submit:hover { filter: brightness(1.03); transform: translateY(-1px); }
|
|
522
|
+
.note {
|
|
523
|
+
margin: 12px 0 0;
|
|
524
|
+
color: var(--muted);
|
|
525
|
+
font-size: 0.9rem;
|
|
526
|
+
}
|
|
527
|
+
.security {
|
|
528
|
+
margin-top: 20px;
|
|
529
|
+
padding: 12px 14px;
|
|
530
|
+
border-radius: 10px;
|
|
531
|
+
background: oklch(0.96 0.025 155);
|
|
532
|
+
color: oklch(0.31 0.07 155);
|
|
533
|
+
font-size: 0.9rem;
|
|
534
|
+
}
|
|
535
|
+
[hidden] { display: none !important; }
|
|
536
|
+
@media (max-width: 860px) {
|
|
537
|
+
.panel { grid-template-columns: 1fr; }
|
|
538
|
+
.aside { padding: 26px; }
|
|
539
|
+
.method { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
540
|
+
.grid { grid-template-columns: 1fr; }
|
|
541
|
+
}
|
|
542
|
+
@media (max-width: 520px) {
|
|
543
|
+
.shell { width: min(100vw - 20px, 1080px); padding: 10px 0; }
|
|
544
|
+
.content { padding: 20px; }
|
|
545
|
+
.topbar { align-items: flex-start; flex-direction: column; }
|
|
546
|
+
.method { grid-template-columns: 1fr; }
|
|
547
|
+
h1 { font-size: 1.55rem; }
|
|
548
|
+
}
|
|
549
|
+
@media (prefers-reduced-motion: reduce) {
|
|
550
|
+
*, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
|
|
551
|
+
}
|
|
222
552
|
</style>
|
|
223
553
|
</head>
|
|
224
554
|
<body>
|
|
225
|
-
<main>
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
555
|
+
<main class="shell">
|
|
556
|
+
<section class="panel" aria-labelledby="title">
|
|
557
|
+
<aside class="aside">
|
|
558
|
+
<div>
|
|
559
|
+
<div class="brand"><span class="mark">A</span><span data-i18n="brand">APIFM Admin MCP</span></div>
|
|
560
|
+
<h1 id="title" data-i18n="title">安全连接你的 APIFM 后台</h1>
|
|
561
|
+
<p class="lead" data-i18n="lead">在本机页面完成授权,密钥只保存在当前 MCP 进程内存,不会进入聊天上下文。</p>
|
|
562
|
+
<ul class="trust">
|
|
563
|
+
<li><span class="dot"></span><span data-i18n="trustLocal">本地 127.0.0.1 页面提交</span></li>
|
|
564
|
+
<li><span class="dot"></span><span data-i18n="trustMemory">凭证仅在内存中保存</span></li>
|
|
565
|
+
<li><span class="dot"></span><span data-i18n="trustSwitch">支持多个后台账号切换</span></li>
|
|
566
|
+
</ul>
|
|
567
|
+
</div>
|
|
568
|
+
<p class="lead" data-i18n="footer">授权完成后回到 Agent,重新执行刚才的后台操作。</p>
|
|
569
|
+
</aside>
|
|
570
|
+
<div class="content">
|
|
571
|
+
<div class="topbar">
|
|
572
|
+
<div>
|
|
573
|
+
<strong data-i18n="formTitle">选择授权方式</strong>
|
|
574
|
+
<div class="meta"><span data-i18n="expires">页面过期时间</span>: ${escapeHtml(new Date(expiresAt).toLocaleString())}</div>
|
|
575
|
+
</div>
|
|
576
|
+
<div class="lang" aria-label="Language">
|
|
577
|
+
<button type="button" data-lang="zh-CN" aria-pressed="true">简</button>
|
|
578
|
+
<button type="button" data-lang="zh-TW" aria-pressed="false">繁</button>
|
|
579
|
+
<button type="button" data-lang="en" aria-pressed="false">EN</button>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
<div class="method" role="tablist" aria-label="Auth methods">
|
|
584
|
+
${renderMethodButton('username-password', initialType)}
|
|
585
|
+
${renderMethodButton('email-password', initialType)}
|
|
586
|
+
${renderMethodButton('basic', initialType)}
|
|
587
|
+
${renderMethodButton('x-token', initialType)}
|
|
588
|
+
${renderMethodButton('register-email', initialType)}
|
|
589
|
+
</div>
|
|
590
|
+
|
|
591
|
+
<form method="post" autocomplete="off" id="authForm">
|
|
592
|
+
<input type="hidden" id="authType" name="authType" value="${escapeHtml(initialType)}">
|
|
593
|
+
<div class="field">
|
|
594
|
+
<label for="alias" data-i18n="alias">账号别名</label>
|
|
595
|
+
<input id="alias" name="alias" value="${escapeHtml(alias)}" placeholder="default-admin" data-i18n-placeholder="aliasPh">
|
|
596
|
+
</div>
|
|
597
|
+
|
|
598
|
+
<section class="section" data-section="username-password">
|
|
599
|
+
<p class="section-head" data-i18n="usernameHint">使用后台用户名和密码登录,MCP 会通过 SDK 换取 X-Token。</p>
|
|
600
|
+
<div class="grid">
|
|
601
|
+
<div class="field">
|
|
602
|
+
<label for="loginUsername" data-i18n="username">用户名</label>
|
|
603
|
+
<input id="loginUsername" name="loginUsername" data-i18n-placeholder="usernamePh">
|
|
604
|
+
</div>
|
|
605
|
+
<div class="field">
|
|
606
|
+
<label for="loginPasswordUser" data-i18n="password">密码</label>
|
|
607
|
+
<input id="loginPasswordUser" name="loginPassword" type="password" data-i18n-placeholder="passwordPh">
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
<div class="field" style="margin-top:14px">
|
|
611
|
+
<label for="pdomain" data-i18n="pdomain">专属域名,可选</label>
|
|
612
|
+
<input id="pdomain" name="pdomain" data-i18n-placeholder="pdomainPh">
|
|
613
|
+
</div>
|
|
614
|
+
</section>
|
|
615
|
+
|
|
616
|
+
<section class="section" data-section="email-password">
|
|
617
|
+
<p class="section-head" data-i18n="emailHint">使用后台邮箱和密码登录,MCP 会通过 SDK 换取 X-Token。</p>
|
|
618
|
+
<div class="grid">
|
|
619
|
+
<div class="field">
|
|
620
|
+
<label for="loginEmail" data-i18n="email">邮箱地址</label>
|
|
621
|
+
<input id="loginEmail" name="loginEmail" type="email" data-i18n-placeholder="emailPh">
|
|
622
|
+
</div>
|
|
623
|
+
<div class="field">
|
|
624
|
+
<label for="loginPasswordEmail" data-i18n="password">密码</label>
|
|
625
|
+
<input id="loginPasswordEmail" name="loginPassword" type="password" data-i18n-placeholder="passwordPh">
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
</section>
|
|
629
|
+
|
|
630
|
+
<section class="section" data-section="basic">
|
|
631
|
+
<p class="section-head" data-i18n="basicHint">填写 Basic Authentication 信息,将作为 Authorization 请求头调用后台接口。</p>
|
|
632
|
+
<div class="grid">
|
|
633
|
+
<div class="field">
|
|
634
|
+
<label for="basicUsername" data-i18n="basicUser">Basic 用户名</label>
|
|
635
|
+
<input id="basicUsername" name="basicUsername" data-i18n-placeholder="basicUserPh">
|
|
636
|
+
</div>
|
|
637
|
+
<div class="field">
|
|
638
|
+
<label for="basicPassword" data-i18n="basicPass">Basic 密码</label>
|
|
639
|
+
<input id="basicPassword" name="basicPassword" type="password" data-i18n-placeholder="basicPassPh">
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
</section>
|
|
643
|
+
|
|
644
|
+
<section class="section" data-section="x-token">
|
|
645
|
+
<p class="section-head" data-i18n="tokenHint">直接填写管理员登录后的 X-Token,用于后续所有后台接口调用。</p>
|
|
646
|
+
<div class="field">
|
|
647
|
+
<label for="xToken" data-i18n="xToken">X-Token</label>
|
|
648
|
+
<input id="xToken" name="xToken" type="password" data-i18n-placeholder="xTokenPh">
|
|
649
|
+
</div>
|
|
650
|
+
</section>
|
|
651
|
+
|
|
652
|
+
<section class="section" data-section="register-email">
|
|
653
|
+
<p class="section-head" data-i18n="registerHint">使用邮箱注册开通新后台账号。需要验证码时,请先通过 SDK 获取邮箱验证码。</p>
|
|
654
|
+
<div class="grid">
|
|
655
|
+
<div class="field">
|
|
656
|
+
<label for="registerEmail" data-i18n="email">邮箱地址</label>
|
|
657
|
+
<input id="registerEmail" name="registerEmail" type="email" data-i18n-placeholder="emailPh">
|
|
658
|
+
</div>
|
|
659
|
+
<div class="field">
|
|
660
|
+
<label for="registerPassword" data-i18n="newPassword">登录密码</label>
|
|
661
|
+
<input id="registerPassword" name="registerPassword" type="password" data-i18n-placeholder="newPasswordPh">
|
|
662
|
+
</div>
|
|
663
|
+
<div class="field">
|
|
664
|
+
<label for="registerName" data-i18n="name">姓名或昵称</label>
|
|
665
|
+
<input id="registerName" name="registerName" data-i18n-placeholder="namePh">
|
|
666
|
+
</div>
|
|
667
|
+
<div class="field">
|
|
668
|
+
<label for="mailCode" data-i18n="mailCode">邮箱验证码</label>
|
|
669
|
+
<input id="mailCode" name="mailCode" data-i18n-placeholder="mailCodePh">
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
</section>
|
|
673
|
+
|
|
674
|
+
<button class="submit" type="submit" data-i18n="submit">完成授权</button>
|
|
675
|
+
</form>
|
|
676
|
+
<div class="security" data-i18n="security">请不要把密码、Token 或 Basic Authentication 内容粘贴到聊天窗口。</div>
|
|
677
|
+
<p class="note" data-i18n="closeNote">提交成功后可以关闭本页面。</p>
|
|
678
|
+
</div>
|
|
679
|
+
</section>
|
|
235
680
|
</main>
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
681
|
+
<script>
|
|
682
|
+
const messages = ${JSON.stringify(I18N)}
|
|
683
|
+
const initialType = ${JSON.stringify(initialType)}
|
|
684
|
+
let activeLang = localStorage.getItem('apifm-auth-lang') || 'zh-CN'
|
|
239
685
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
if (authType === 'x-token') {
|
|
249
|
-
return `
|
|
250
|
-
<label for="token">X-Token</label>
|
|
251
|
-
<input id="token" name="token" type="password" required>`
|
|
686
|
+
const authTypeInput = document.querySelector('#authType')
|
|
687
|
+
const sections = [...document.querySelectorAll('[data-section]')]
|
|
688
|
+
const methodButtons = [...document.querySelectorAll('[data-method]')]
|
|
689
|
+
const langButtons = [...document.querySelectorAll('[data-lang]')]
|
|
690
|
+
|
|
691
|
+
function t(key) {
|
|
692
|
+
return messages[activeLang][key] || messages['zh-CN'][key] || key
|
|
252
693
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
<input id="pdomain" name="pdomain">`
|
|
694
|
+
|
|
695
|
+
function setLang(lang) {
|
|
696
|
+
activeLang = messages[lang] ? lang : 'zh-CN'
|
|
697
|
+
localStorage.setItem('apifm-auth-lang', activeLang)
|
|
698
|
+
document.documentElement.lang = activeLang
|
|
699
|
+
document.querySelectorAll('[data-i18n]').forEach((node) => {
|
|
700
|
+
node.textContent = t(node.dataset.i18n)
|
|
701
|
+
})
|
|
702
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => {
|
|
703
|
+
node.placeholder = t(node.dataset.i18nPlaceholder)
|
|
704
|
+
})
|
|
705
|
+
langButtons.forEach((button) => {
|
|
706
|
+
button.setAttribute('aria-pressed', String(button.dataset.lang === activeLang))
|
|
707
|
+
})
|
|
268
708
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
709
|
+
|
|
710
|
+
function setMethod(method) {
|
|
711
|
+
authTypeInput.value = method
|
|
712
|
+
sections.forEach((section) => {
|
|
713
|
+
const active = section.dataset.section === method
|
|
714
|
+
section.classList.toggle('active', active)
|
|
715
|
+
section.querySelectorAll('input').forEach((input) => {
|
|
716
|
+
input.disabled = !active
|
|
717
|
+
input.required = active && input.type !== 'hidden' && !['pdomain', 'registerName', 'mailCode'].includes(input.name)
|
|
718
|
+
})
|
|
719
|
+
})
|
|
720
|
+
methodButtons.forEach((button) => {
|
|
721
|
+
button.setAttribute('aria-pressed', String(button.dataset.method === method))
|
|
722
|
+
})
|
|
279
723
|
}
|
|
280
|
-
|
|
724
|
+
|
|
725
|
+
methodButtons.forEach((button) => button.addEventListener('click', () => setMethod(button.dataset.method)))
|
|
726
|
+
langButtons.forEach((button) => button.addEventListener('click', () => setLang(button.dataset.lang)))
|
|
727
|
+
setLang(activeLang)
|
|
728
|
+
setMethod(initialType)
|
|
729
|
+
</script>
|
|
730
|
+
</body>
|
|
731
|
+
</html>`
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function renderMethodButton(method, activeType) {
|
|
735
|
+
const keys = {
|
|
736
|
+
'username-password': ['methodUsername', 'methodUsernameSub'],
|
|
737
|
+
'email-password': ['methodEmail', 'methodEmailSub'],
|
|
738
|
+
basic: ['methodBasic', 'methodBasicSub'],
|
|
739
|
+
'x-token': ['methodToken', 'methodTokenSub'],
|
|
740
|
+
'register-email': ['methodRegister', 'methodRegisterSub']
|
|
741
|
+
}[method]
|
|
742
|
+
return `<button type="button" role="tab" data-method="${method}" aria-pressed="${method === activeType}">
|
|
743
|
+
<strong data-i18n="${keys[0]}"></strong>
|
|
744
|
+
<span data-i18n="${keys[1]}"></span>
|
|
745
|
+
</button>`
|
|
281
746
|
}
|
|
282
747
|
|
|
283
748
|
function renderSuccess(account) {
|
|
284
|
-
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><body style="font:16px system-ui;padding:40px"><h1
|
|
749
|
+
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><body style="font:16px system-ui;padding:40px;background:#f7f8fb;color:#202124"><main style="max-width:560px;margin:10vh auto;background:white;border:1px solid #dde2ea;border-radius:14px;padding:32px"><h1>授权成功</h1><p>账号 <strong>${escapeHtml(account.alias)}</strong> 已准备好。你可以关闭本页面,回到 Agent 继续操作。</p></main></body></html>`
|
|
285
750
|
}
|
|
286
751
|
|
|
287
752
|
function renderError(error) {
|
package/src/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
summarizeMetadata
|
|
22
22
|
} from './sdk.js'
|
|
23
23
|
|
|
24
|
-
const VERSION = '26.5.
|
|
24
|
+
const VERSION = '26.5.3'
|
|
25
25
|
|
|
26
26
|
const server = new McpServer(
|
|
27
27
|
{
|
|
@@ -32,8 +32,9 @@ const server = new McpServer(
|
|
|
32
32
|
instructions: [
|
|
33
33
|
'Use this MCP server to operate APIFM admin APIs through the apifm-admin SDK.',
|
|
34
34
|
'Never ask users to paste passwords, Basic Authentication values, X-Token values, or API keys into chat.',
|
|
35
|
-
'Use apifm_admin_start_auth to collect secrets through a local browser page, then call APIs by account alias.',
|
|
36
|
-
'
|
|
35
|
+
'Use apifm_admin_start_auth to collect secrets through a local browser page, then call APIs by account alias. If an API call is attempted before authorization, the tool will return an authorization URL directly.',
|
|
36
|
+
'For user requests that ask to read, create, update, delete, or operate backend data, call apifm_admin_find_and_call or apifm_admin_call and return the live apiResult.',
|
|
37
|
+
'apifm_admin_search_methods and apifm_admin_method_info are only planning helpers; do not treat their parameter documentation as the final answer.'
|
|
37
38
|
].join('\n')
|
|
38
39
|
}
|
|
39
40
|
)
|
|
@@ -46,9 +47,10 @@ server.registerTool(
|
|
|
46
47
|
'Creates a localhost browser page for entering Basic Auth, X-Token, username/password, or email registration details. Secrets are stored only in this MCP process memory and must not be pasted into chat.',
|
|
47
48
|
inputSchema: z.object({
|
|
48
49
|
authType: z
|
|
49
|
-
.enum(['basic', 'x-token', 'password', 'register-email'])
|
|
50
|
+
.enum(['all', 'basic', 'x-token', 'password', 'username-password', 'email-password', 'register-email'])
|
|
51
|
+
.optional()
|
|
50
52
|
.describe(
|
|
51
|
-
'basic = Basic Authentication, x-token = existing admin X-Token, password
|
|
53
|
+
'Optional initial auth method. all shows every method in one browser page. basic = Basic Authentication, x-token = existing admin X-Token, username-password/email-password = SDK login, register-email = create admin account by email.'
|
|
52
54
|
),
|
|
53
55
|
alias: z.string().optional().describe('Friendly local account alias, for example prod or test-shop.'),
|
|
54
56
|
domains: z
|
|
@@ -58,7 +60,7 @@ server.registerTool(
|
|
|
58
60
|
port: z.number().int().min(0).max(65535).optional().describe('Optional localhost port. 0 chooses a free port.')
|
|
59
61
|
})
|
|
60
62
|
},
|
|
61
|
-
async ({ authType, alias, domains, port }) => {
|
|
63
|
+
async ({ authType = 'all', alias, domains, port }) => {
|
|
62
64
|
const session = await createAuthSession({ authType, alias, domains, port })
|
|
63
65
|
return toolJson({
|
|
64
66
|
...session,
|
|
@@ -114,7 +116,7 @@ server.registerTool(
|
|
|
114
116
|
{
|
|
115
117
|
title: 'Search apifm-admin SDK methods',
|
|
116
118
|
description:
|
|
117
|
-
'Searches
|
|
119
|
+
'Planning helper only. Searches SDK methods and parameter docs, but does not call the API. After choosing a method, use apifm_admin_call or apifm_admin_find_and_call to get live API data.',
|
|
118
120
|
inputSchema: z.object({
|
|
119
121
|
query: z.string().optional(),
|
|
120
122
|
limit: z.number().int().min(1).max(100).optional()
|
|
@@ -131,7 +133,8 @@ server.registerTool(
|
|
|
131
133
|
'apifm_admin_method_info',
|
|
132
134
|
{
|
|
133
135
|
title: 'Get apifm-admin SDK method info',
|
|
134
|
-
description:
|
|
136
|
+
description:
|
|
137
|
+
'Planning helper only. Returns metadata for one SDK method, including route, HTTP method, parameters, and return example. This is not live API data.',
|
|
135
138
|
inputSchema: z.object({
|
|
136
139
|
methodName: z.string().min(1)
|
|
137
140
|
})
|
|
@@ -150,7 +153,7 @@ server.registerTool(
|
|
|
150
153
|
{
|
|
151
154
|
title: 'Call an APIFM admin API',
|
|
152
155
|
description:
|
|
153
|
-
'Calls
|
|
156
|
+
'Primary execution tool. Calls an apifm-admin SDK method by name and returns the live backend API response in apiResult. Use this when the user wants actual data or an operation performed.',
|
|
154
157
|
inputSchema: z.object({
|
|
155
158
|
methodName: z.string().min(1).describe('apifm-admin SDK method name, for example userList.'),
|
|
156
159
|
payload: z.record(z.string(), z.any()).optional().describe('Non-sensitive SDK payload.'),
|
|
@@ -162,54 +165,59 @@ server.registerTool(
|
|
|
162
165
|
headers: z
|
|
163
166
|
.record(z.string(), z.string())
|
|
164
167
|
.optional()
|
|
165
|
-
.describe('Optional non-sensitive extra headers. Authorization and token headers are rejected.')
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
}
|
|
175
|
-
const sensitiveHeaderPath = containsSensitiveKey(headers)
|
|
176
|
-
if (sensitiveHeaderPath) {
|
|
177
|
-
return toolError(
|
|
178
|
-
`Refusing to call ${methodName}: headers contain a sensitive field at "${sensitiveHeaderPath}". Use apifm_admin_start_auth for secrets.`
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const account = getAccount(alias)
|
|
183
|
-
if (!account) {
|
|
184
|
-
return toolError(
|
|
185
|
-
'No APIFM admin account is authorized. Call apifm_admin_start_auth first, then use the returned local browser URL.'
|
|
186
|
-
)
|
|
168
|
+
.describe('Optional non-sensitive extra headers. Authorization and token headers are rejected.'),
|
|
169
|
+
includeMetadata: z
|
|
170
|
+
.boolean()
|
|
171
|
+
.optional()
|
|
172
|
+
.describe('Set true only when debugging. Defaults to false so the response stays focused on live API data.')
|
|
173
|
+
}),
|
|
174
|
+
annotations: {
|
|
175
|
+
openWorldHint: true
|
|
187
176
|
}
|
|
177
|
+
},
|
|
178
|
+
async ({ methodName, payload = {}, alias, domains, headers = {}, includeMetadata = false }) =>
|
|
179
|
+
callToolResult({ methodName, payload, alias, domains, headers, includeMetadata })
|
|
180
|
+
)
|
|
188
181
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
182
|
+
server.registerTool(
|
|
183
|
+
'apifm_admin_find_and_call',
|
|
184
|
+
{
|
|
185
|
+
title: 'Find and call an APIFM admin API',
|
|
186
|
+
description:
|
|
187
|
+
'Best primary tool for natural-language backend requests. Searches the installed apifm-admin SDK, selects the best matching callable method, calls it, and returns the live backend API response in apiResult.',
|
|
188
|
+
inputSchema: z.object({
|
|
189
|
+
query: z
|
|
190
|
+
.string()
|
|
191
|
+
.min(1)
|
|
192
|
+
.describe('Natural-language intent or keywords, for example 用户列表, 订单详情, 注册邮箱验证码.'),
|
|
193
|
+
payload: z.record(z.string(), z.any()).optional().describe('Non-sensitive SDK payload for the selected API.'),
|
|
194
|
+
alias: z.string().optional().describe('Optional local account alias. Defaults to the active account.'),
|
|
195
|
+
domains: z.record(z.string(), z.string().url()).optional().describe('Optional per-call domain overrides.'),
|
|
196
|
+
headers: z.record(z.string(), z.string()).optional().describe('Optional non-sensitive extra headers.'),
|
|
197
|
+
methodName: z
|
|
198
|
+
.string()
|
|
199
|
+
.optional()
|
|
200
|
+
.describe('Optional exact method name. If provided, search is skipped and this method is called.'),
|
|
201
|
+
includeMetadata: z.boolean().optional().describe('Set true only when debugging.')
|
|
202
|
+
}),
|
|
203
|
+
annotations: {
|
|
204
|
+
openWorldHint: true
|
|
192
205
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
sdkHeaders.Authorization = account.basicAuth
|
|
206
|
+
},
|
|
207
|
+
async ({ query, payload = {}, alias, domains, headers = {}, methodName, includeMetadata = false }) => {
|
|
208
|
+
const selectedMethod = methodName || findCallableMethod(query)
|
|
209
|
+
if (!selectedMethod) {
|
|
210
|
+
return toolError(`No callable apifm-admin SDK method matched query: ${query}`)
|
|
199
211
|
}
|
|
200
212
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
methodName,
|
|
210
|
-
accountAlias: account.alias,
|
|
211
|
-
metadata: getMethodMetadata(methodName) ? summarizeMetadata(getMethodMetadata(methodName)) : null,
|
|
212
|
-
result: redactSensitive(result)
|
|
213
|
+
return callToolResult({
|
|
214
|
+
methodName: selectedMethod,
|
|
215
|
+
payload,
|
|
216
|
+
alias,
|
|
217
|
+
domains,
|
|
218
|
+
headers,
|
|
219
|
+
includeMetadata,
|
|
220
|
+
searchQuery: query
|
|
213
221
|
})
|
|
214
222
|
}
|
|
215
223
|
)
|
|
@@ -245,7 +253,89 @@ function toolJson(data) {
|
|
|
245
253
|
}
|
|
246
254
|
}
|
|
247
255
|
|
|
248
|
-
function
|
|
256
|
+
async function callToolResult({
|
|
257
|
+
methodName,
|
|
258
|
+
payload = {},
|
|
259
|
+
alias,
|
|
260
|
+
domains,
|
|
261
|
+
headers = {},
|
|
262
|
+
includeMetadata = false,
|
|
263
|
+
searchQuery
|
|
264
|
+
}) {
|
|
265
|
+
const sensitivePayloadPath = containsSensitiveKey(payload)
|
|
266
|
+
if (sensitivePayloadPath) {
|
|
267
|
+
return toolError(
|
|
268
|
+
`Refusing to call ${methodName}: payload contains a sensitive field at "${sensitivePayloadPath}". Use apifm_admin_start_auth for secrets.`
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
const sensitiveHeaderPath = containsSensitiveKey(headers)
|
|
272
|
+
if (sensitiveHeaderPath) {
|
|
273
|
+
return toolError(
|
|
274
|
+
`Refusing to call ${methodName}: headers contain a sensitive field at "${sensitiveHeaderPath}". Use apifm_admin_start_auth for secrets.`
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const account = getAccount(alias)
|
|
279
|
+
if (!account) {
|
|
280
|
+
const session = await createAuthSession({ authType: 'all', alias, domains })
|
|
281
|
+
return toolError(
|
|
282
|
+
[
|
|
283
|
+
'No APIFM admin account is authorized.',
|
|
284
|
+
`Open this local authorization URL now: ${session.url}`,
|
|
285
|
+
'Complete authorization in the browser, then run the same API call again.'
|
|
286
|
+
].join('\n'),
|
|
287
|
+
{
|
|
288
|
+
reason: 'authorization_required',
|
|
289
|
+
authUrl: session.url,
|
|
290
|
+
sessionId: session.sessionId,
|
|
291
|
+
expiresAt: session.expiresAt,
|
|
292
|
+
nextStep: 'Open authUrl in a browser, complete authorization, then retry the same tool call.'
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const sdk = getSdk()
|
|
298
|
+
if (typeof sdk[methodName] !== 'function') {
|
|
299
|
+
return toolError(`apifm-admin does not expose a function named ${methodName}. Search methods first.`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
resetSdkConfig()
|
|
303
|
+
const allDomains = { ...(account.domains || {}), ...(domains || {}) }
|
|
304
|
+
const sdkHeaders = { ...headers }
|
|
305
|
+
if (account.basicAuth) {
|
|
306
|
+
sdkHeaders.Authorization = account.basicAuth
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
sdk.setConfig({
|
|
310
|
+
token: account.token || '',
|
|
311
|
+
headers: sdkHeaders,
|
|
312
|
+
domains: allDomains
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const apiResult = redactSensitive(await sdk[methodName](payload))
|
|
316
|
+
const response = {
|
|
317
|
+
apiResult,
|
|
318
|
+
called: {
|
|
319
|
+
methodName,
|
|
320
|
+
accountAlias: account.alias,
|
|
321
|
+
searchQuery: searchQuery || undefined
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (includeMetadata) {
|
|
326
|
+
response.metadata = getMethodMetadata(methodName) ? summarizeMetadata(getMethodMetadata(methodName)) : null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return toolJson(response)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function findCallableMethod(query) {
|
|
333
|
+
const sdk = getSdk()
|
|
334
|
+
const candidates = searchMethods(query, 20)
|
|
335
|
+
return candidates.find((candidate) => typeof sdk[candidate.methodName] === 'function')?.methodName || ''
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function toolError(message, extra = {}) {
|
|
249
339
|
return {
|
|
250
340
|
isError: true,
|
|
251
341
|
content: [
|
|
@@ -254,7 +344,7 @@ function toolError(message) {
|
|
|
254
344
|
text: message
|
|
255
345
|
}
|
|
256
346
|
],
|
|
257
|
-
structuredContent: { error: message }
|
|
347
|
+
structuredContent: { error: message, ...extra }
|
|
258
348
|
}
|
|
259
349
|
}
|
|
260
350
|
|
package/src/self-check.js
CHANGED
|
@@ -23,6 +23,7 @@ try {
|
|
|
23
23
|
'apifm_admin_accounts',
|
|
24
24
|
'apifm_admin_search_methods',
|
|
25
25
|
'apifm_admin_method_info',
|
|
26
|
+
'apifm_admin_find_and_call',
|
|
26
27
|
'apifm_admin_call'
|
|
27
28
|
]
|
|
28
29
|
for (const name of requiredTools) {
|
|
@@ -31,9 +32,24 @@ try {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
const auth = await client.callTool({
|
|
36
|
+
name: 'apifm_admin_start_auth',
|
|
37
|
+
arguments: {}
|
|
38
|
+
})
|
|
39
|
+
const authData = JSON.parse(auth.content[0].text)
|
|
40
|
+
if (!authData.url?.startsWith('http://127.0.0.1:')) {
|
|
41
|
+
throw new Error('start_auth should return a local authorization URL.')
|
|
42
|
+
}
|
|
43
|
+
const authPage = await fetch(authData.url).then((response) => response.text())
|
|
44
|
+
for (const expected of ['data-lang="zh-CN"', 'data-lang="zh-TW"', 'data-lang="en"', 'data-method="basic"', 'data-method="x-token"']) {
|
|
45
|
+
if (!authPage.includes(expected)) {
|
|
46
|
+
throw new Error(`Authorization page is missing ${expected}.`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
const search = await client.callTool({
|
|
35
51
|
name: 'apifm_admin_search_methods',
|
|
36
|
-
arguments: { query: '
|
|
52
|
+
arguments: { query: 'user list', limit: 5 }
|
|
37
53
|
})
|
|
38
54
|
const searchData = JSON.parse(search.content[0].text)
|
|
39
55
|
if (!searchData.sdkMethodCount || !Array.isArray(searchData.results)) {
|
|
@@ -51,6 +67,21 @@ try {
|
|
|
51
67
|
throw new Error('Sensitive payload guard did not reject password fields.')
|
|
52
68
|
}
|
|
53
69
|
|
|
70
|
+
const findAndCall = await client.callTool({
|
|
71
|
+
name: 'apifm_admin_find_and_call',
|
|
72
|
+
arguments: {
|
|
73
|
+
query: 'user list',
|
|
74
|
+
payload: { page: 1, pageSize: 1 }
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
const findAndCallData = findAndCall.structuredContent || {}
|
|
78
|
+
if (!findAndCall.isError || !findAndCallData.authUrl?.startsWith('http://127.0.0.1:')) {
|
|
79
|
+
throw new Error('find_and_call should return a local authUrl when authorization is missing.')
|
|
80
|
+
}
|
|
81
|
+
if (!findAndCall.content[0].text.includes(findAndCallData.authUrl)) {
|
|
82
|
+
throw new Error('find_and_call should show the authUrl in the visible tool text.')
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
await client.close()
|
|
55
86
|
console.log(`self-check passed: ${names.length} tools, ${searchData.sdkMethodCount} SDK methods discovered`)
|
|
56
87
|
} catch (error) {
|