apifm-admin-mcp 26.5.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # apifm-admin-mcp
2
+
3
+ `apifm-admin-mcp` is a stdio MCP server for AI agents that need to operate APIFM admin APIs through the [`apifm-admin`](https://www.npmjs.com/package/apifm-admin) SDK.
4
+
5
+ It is designed for Kiro, Cursor, Claude Code, Codex, Windsurf, Trae, Qoder, and other MCP-compatible agents.
6
+
7
+ ## Security Model
8
+
9
+ Secrets must not be pasted into model chat.
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`, or username/email password login details in that local browser page. Those secrets stay in the local MCP process memory and are never part of MCP tool arguments.
12
+
13
+ The generic API caller also rejects payloads and headers containing sensitive field names such as `pwd`, `password`, `token`, `x-token`, `authorization`, `secret`, or `key`.
14
+
15
+ Accounts are in-memory only. Restarting the MCP server clears all tokens and credentials.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install -g apifm-admin-mcp
21
+ ```
22
+
23
+ The package depends on `apifm-admin` as a normal npm dependency. It does not bundle SDK source code, so newer compatible `apifm-admin` releases can add methods without requiring this MCP package to be regenerated.
24
+
25
+ ## MCP Configuration
26
+
27
+ Use stdio:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "apifm-admin": {
33
+ "command": "npx",
34
+ "args": ["apifm-admin-mcp"]
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ If installed globally:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "apifm-admin": {
46
+ "command": "apifm-admin-mcp"
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Tools
53
+
54
+ - `apifm_admin_start_auth`: Starts a local secure authorization page for Basic Auth, X-Token, username/password login, or email registration.
55
+ - `apifm_admin_accounts`: Lists local account aliases without revealing secrets.
56
+ - `apifm_admin_switch_account`: Switches the active account alias.
57
+ - `apifm_admin_remove_account`: Clears an account alias and its in-memory secrets.
58
+ - `apifm_admin_search_methods`: Searches methods dynamically from the installed `apifm-admin` SDK.
59
+ - `apifm_admin_method_info`: Shows route, method, parameters, and usage for one SDK method.
60
+ - `apifm_admin_call`: Calls any non-sensitive SDK method using the selected local account.
61
+
62
+ ## Example Agent Flow
63
+
64
+ 1. User: “登录我的后台账号并读取用户列表。”
65
+ 2. Agent calls `apifm_admin_start_auth` with `authType: "password"`.
66
+ 3. User opens the returned local URL and enters username/email and password there.
67
+ 4. Agent calls `apifm_admin_search_methods` with `query: "用户列表"`.
68
+ 5. Agent calls `apifm_admin_call` with the discovered method and a non-sensitive payload.
69
+
70
+ ## Local Check
71
+
72
+ ```bash
73
+ npm run check
74
+ ```
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "apifm-admin-mcp",
3
+ "version": "26.5.1",
4
+ "description": "MCP server for safely operating APIFM admin APIs through the apifm-admin SDK.",
5
+ "type": "module",
6
+ "bin": {
7
+ "apifm-admin-mcp": "./src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "start": "node ./src/index.js",
16
+ "check": "node ./src/self-check.js",
17
+ "prepublishOnly": "npm run check"
18
+ },
19
+ "keywords": [
20
+ "apifm",
21
+ "apifm-admin",
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "cursor",
25
+ "claude-code",
26
+ "codex",
27
+ "windsurf",
28
+ "kiro"
29
+ ],
30
+ "author": "",
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=20.11"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.29.0",
37
+ "apifm-admin": "^26.6.16",
38
+ "zod": "^4.4.3"
39
+ }
40
+ }
@@ -0,0 +1,66 @@
1
+ import crypto from 'node:crypto'
2
+ import { createPublicAccount } from './security.js'
3
+
4
+ const accounts = new Map()
5
+ let activeAlias = ''
6
+
7
+ export function normalizeAlias(alias) {
8
+ const clean = String(alias || '').trim()
9
+ if (!clean) {
10
+ return `admin-${crypto.randomUUID().slice(0, 8)}`
11
+ }
12
+ return clean
13
+ }
14
+
15
+ export function upsertAccount(input) {
16
+ const alias = normalizeAlias(input.alias)
17
+ const now = new Date().toISOString()
18
+ const existing = accounts.get(alias)
19
+ const account = {
20
+ alias,
21
+ authType: input.authType,
22
+ token: input.token || existing?.token || '',
23
+ basicAuth: input.basicAuth || existing?.basicAuth || '',
24
+ domains: input.domains || existing?.domains || {},
25
+ createdAt: existing?.createdAt || now,
26
+ updatedAt: now
27
+ }
28
+
29
+ accounts.set(alias, account)
30
+ activeAlias = alias
31
+ return createPublicAccount(account)
32
+ }
33
+
34
+ export function getAccount(alias) {
35
+ const selectedAlias = normalizeAlias(alias || activeAlias)
36
+ return accounts.get(selectedAlias) || null
37
+ }
38
+
39
+ export function setActiveAccount(alias) {
40
+ const account = accounts.get(normalizeAlias(alias))
41
+ if (!account) {
42
+ throw new Error(`Unknown account alias: ${alias}`)
43
+ }
44
+ activeAlias = account.alias
45
+ return createPublicAccount(account)
46
+ }
47
+
48
+ export function getActiveAlias() {
49
+ return activeAlias
50
+ }
51
+
52
+ export function listAccounts() {
53
+ return [...accounts.values()].map((account) => ({
54
+ ...createPublicAccount(account),
55
+ active: account.alias === activeAlias
56
+ }))
57
+ }
58
+
59
+ export function removeAccount(alias) {
60
+ const selectedAlias = normalizeAlias(alias)
61
+ const deleted = accounts.delete(selectedAlias)
62
+ if (activeAlias === selectedAlias) {
63
+ activeAlias = accounts.keys().next().value || ''
64
+ }
65
+ return deleted
66
+ }
@@ -0,0 +1,289 @@
1
+ import http from 'node:http'
2
+ import crypto from 'node:crypto'
3
+ import { once } from 'node:events'
4
+ import { upsertAccount } from './accounts.js'
5
+ import { getSdk, resetSdkConfig } from './sdk.js'
6
+
7
+ const pendingSessions = new Map()
8
+
9
+ export async function createAuthSession({ authType, alias = '', domains = {}, port = 0 }) {
10
+ const sessionId = crypto.randomUUID()
11
+ const createdAt = Date.now()
12
+ const expiresAt = createdAt + 10 * 60 * 1000
13
+ let server
14
+
15
+ server = http.createServer(async (req, res) => {
16
+ try {
17
+ const url = new URL(req.url, 'http://127.0.0.1')
18
+ if (url.pathname !== `/${sessionId}`) {
19
+ sendText(res, 404, 'Not found')
20
+ return
21
+ }
22
+
23
+ if (req.method === 'GET') {
24
+ sendHtml(res, renderForm({ authType, alias, sessionId, expiresAt }))
25
+ return
26
+ }
27
+
28
+ if (req.method !== 'POST') {
29
+ sendText(res, 405, 'Method not allowed')
30
+ return
31
+ }
32
+
33
+ const fields = await readForm(req)
34
+ const account = await finishAuth({ authType, alias, domains, fields })
35
+ pendingSessions.delete(sessionId)
36
+ sendHtml(res, renderSuccess(account))
37
+ setTimeout(() => server.close(), 250)
38
+ } catch (error) {
39
+ sendHtml(res, renderError(error))
40
+ }
41
+ })
42
+
43
+ server.on('close', () => pendingSessions.delete(sessionId))
44
+ server.listen(port, '127.0.0.1')
45
+ await once(server, 'listening')
46
+
47
+ const address = server.address()
48
+ const url = `http://127.0.0.1:${address.port}/${sessionId}`
49
+ pendingSessions.set(sessionId, { server, authType, alias, createdAt, expiresAt })
50
+ setTimeout(() => {
51
+ const pending = pendingSessions.get(sessionId)
52
+ if (pending) {
53
+ pending.server.close()
54
+ pendingSessions.delete(sessionId)
55
+ }
56
+ }, expiresAt - Date.now()).unref()
57
+
58
+ return {
59
+ url,
60
+ sessionId,
61
+ expiresAt: new Date(expiresAt).toISOString(),
62
+ message:
63
+ 'Open this local URL in a browser and enter credentials there. Do not paste secrets into the chat.'
64
+ }
65
+ }
66
+
67
+ async function finishAuth({ authType, alias, domains, fields }) {
68
+ if (authType === 'basic') {
69
+ const username = required(fields.username, 'username')
70
+ const password = required(fields.password, 'password')
71
+ return upsertAccount({
72
+ alias: fields.alias || alias,
73
+ authType,
74
+ basicAuth: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
75
+ domains
76
+ })
77
+ }
78
+
79
+ if (authType === 'x-token') {
80
+ return upsertAccount({
81
+ alias: fields.alias || alias,
82
+ authType,
83
+ token: required(fields.token, 'X-Token'),
84
+ domains
85
+ })
86
+ }
87
+
88
+ if (authType === 'password') {
89
+ const loginMode = fields.loginMode === 'email' ? 'email' : 'username'
90
+ const loginPayload =
91
+ loginMode === 'email'
92
+ ? {
93
+ email: required(fields.email, 'email'),
94
+ pwd: required(fields.password, 'password'),
95
+ rememberMe: true
96
+ }
97
+ : {
98
+ userName: required(fields.username, 'username'),
99
+ pwd: required(fields.password, 'password'),
100
+ rememberMe: true,
101
+ pdomain: fields.pdomain || undefined
102
+ }
103
+
104
+ const sdk = getSdk()
105
+ resetSdkConfig()
106
+ if (domains && Object.keys(domains).length) {
107
+ sdk.setDomains(domains)
108
+ }
109
+ const methodName = loginMode === 'email' ? 'loginAdminEmail' : 'loginAdminUserName'
110
+ if (typeof sdk[methodName] !== 'function') {
111
+ throw new Error(`apifm-admin does not expose ${methodName}`)
112
+ }
113
+ const result = await sdk[methodName](loginPayload)
114
+ const token = extractToken(result)
115
+ if (!token) {
116
+ throw new Error('Login succeeded but no X-Token was found in the SDK response.')
117
+ }
118
+ return upsertAccount({
119
+ alias: fields.alias || alias,
120
+ authType,
121
+ token,
122
+ domains
123
+ })
124
+ }
125
+
126
+ if (authType === 'register-email') {
127
+ const sdk = getSdk()
128
+ resetSdkConfig()
129
+ if (domains && Object.keys(domains).length) {
130
+ sdk.setDomains(domains)
131
+ }
132
+ const result = await sdk.registerAdminSaveEmail({
133
+ email: required(fields.email, 'email'),
134
+ pwd: required(fields.password, 'password'),
135
+ name: fields.name || undefined,
136
+ mailCode: fields.mailCode || fields.code || undefined
137
+ })
138
+ const token = extractToken(result)
139
+ return upsertAccount({
140
+ alias: fields.alias || alias,
141
+ authType,
142
+ token,
143
+ domains
144
+ })
145
+ }
146
+
147
+ throw new Error(`Unsupported auth type: ${authType}`)
148
+ }
149
+
150
+ function extractToken(response) {
151
+ const candidates = [
152
+ response?.data?.token,
153
+ response?.data?.xToken,
154
+ response?.data?.xtoken,
155
+ response?.data?.x_token,
156
+ response?.data?.['x-token'],
157
+ response?.token,
158
+ response?.xToken,
159
+ response?.xtoken
160
+ ]
161
+ return candidates.find((item) => typeof item === 'string' && item.trim()) || ''
162
+ }
163
+
164
+ function required(value, label) {
165
+ const clean = String(value || '').trim()
166
+ if (!clean) {
167
+ throw new Error(`${label} is required`)
168
+ }
169
+ return clean
170
+ }
171
+
172
+ async function readForm(req) {
173
+ const chunks = []
174
+ for await (const chunk of req) {
175
+ chunks.push(chunk)
176
+ }
177
+ const body = Buffer.concat(chunks).toString('utf8')
178
+ return Object.fromEntries(new URLSearchParams(body))
179
+ }
180
+
181
+ function sendHtml(res, html) {
182
+ res.writeHead(200, {
183
+ 'content-type': 'text/html; charset=utf-8',
184
+ 'cache-control': 'no-store'
185
+ })
186
+ res.end(html)
187
+ }
188
+
189
+ function sendText(res, status, text) {
190
+ res.writeHead(status, {
191
+ 'content-type': 'text/plain; charset=utf-8',
192
+ 'cache-control': 'no-store'
193
+ })
194
+ res.end(text)
195
+ }
196
+
197
+ function escapeHtml(value) {
198
+ return String(value ?? '')
199
+ .replaceAll('&', '&')
200
+ .replaceAll('<', '&lt;')
201
+ .replaceAll('>', '&gt;')
202
+ .replaceAll('"', '&quot;')
203
+ }
204
+
205
+ function renderForm({ authType, alias, expiresAt }) {
206
+ const fields = renderFields(authType)
207
+ return `<!doctype html>
208
+ <html lang="zh-CN">
209
+ <head>
210
+ <meta charset="utf-8">
211
+ <meta name="viewport" content="width=device-width, initial-scale=1">
212
+ <title>APIFM Admin MCP Authorization</title>
213
+ <style>
214
+ body { margin: 0; font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f7f7f4; color: #202124; }
215
+ main { max-width: 560px; margin: 8vh auto; padding: 32px; background: #fff; border: 1px solid #deded8; border-radius: 8px; }
216
+ h1 { margin: 0 0 8px; font-size: 22px; }
217
+ p { margin: 0 0 20px; color: #5f6368; }
218
+ label { display: block; margin: 14px 0 6px; font-weight: 650; }
219
+ input, select { width: 100%; box-sizing: border-box; padding: 11px 12px; border: 1px solid #c7c7c0; border-radius: 6px; font: inherit; }
220
+ button { margin-top: 22px; width: 100%; padding: 12px 14px; border: 0; border-radius: 6px; background: #1f6feb; color: white; font: inherit; font-weight: 700; cursor: pointer; }
221
+ .note { margin-top: 18px; font-size: 13px; color: #6a6f75; }
222
+ </style>
223
+ </head>
224
+ <body>
225
+ <main>
226
+ <h1>APIFM Admin MCP Authorization</h1>
227
+ <p>Secrets entered here stay inside this local MCP process memory and are not sent through the chat transcript.</p>
228
+ <form method="post" autocomplete="off">
229
+ <label for="alias">Account alias</label>
230
+ <input id="alias" name="alias" value="${escapeHtml(alias)}" placeholder="default-admin">
231
+ ${fields}
232
+ <button type="submit">Authorize</button>
233
+ </form>
234
+ <p class="note">This local page expires at ${escapeHtml(new Date(expiresAt).toLocaleString())}. Close it after authorization.</p>
235
+ </main>
236
+ </body>
237
+ </html>`
238
+ }
239
+
240
+ function renderFields(authType) {
241
+ if (authType === 'basic') {
242
+ return `
243
+ <label for="username">Basic username</label>
244
+ <input id="username" name="username" required>
245
+ <label for="password">Basic password</label>
246
+ <input id="password" name="password" type="password" required>`
247
+ }
248
+ if (authType === 'x-token') {
249
+ return `
250
+ <label for="token">X-Token</label>
251
+ <input id="token" name="token" type="password" required>`
252
+ }
253
+ if (authType === 'password') {
254
+ return `
255
+ <label for="loginMode">Login mode</label>
256
+ <select id="loginMode" name="loginMode">
257
+ <option value="username">Username and password</option>
258
+ <option value="email">Email and password</option>
259
+ </select>
260
+ <label for="username">Username</label>
261
+ <input id="username" name="username">
262
+ <label for="email">Email</label>
263
+ <input id="email" name="email" type="email">
264
+ <label for="password">Password</label>
265
+ <input id="password" name="password" type="password" required>
266
+ <label for="pdomain">Private domain (optional)</label>
267
+ <input id="pdomain" name="pdomain">`
268
+ }
269
+ if (authType === 'register-email') {
270
+ return `
271
+ <label for="email">Email</label>
272
+ <input id="email" name="email" type="email" required>
273
+ <label for="password">Password</label>
274
+ <input id="password" name="password" type="password" required>
275
+ <label for="name">Name</label>
276
+ <input id="name" name="name">
277
+ <label for="mailCode">Email verification code</label>
278
+ <input id="mailCode" name="mailCode">`
279
+ }
280
+ return ''
281
+ }
282
+
283
+ function renderSuccess(account) {
284
+ return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><body style="font:16px system-ui;padding:40px"><h1>Authorized</h1><p>Account <strong>${escapeHtml(account.alias)}</strong> is ready. You can close this tab.</p></body></html>`
285
+ }
286
+
287
+ function renderError(error) {
288
+ return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><body style="font:16px system-ui;padding:40px"><h1>Authorization failed</h1><p>${escapeHtml(error.message)}</p><p>Go back, check the values, and submit again.</p></body></html>`
289
+ }
package/src/index.js ADDED
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
+ import { z } from 'zod/v4'
6
+ import { createAuthSession } from './browser-auth.js'
7
+ import {
8
+ getAccount,
9
+ getActiveAlias,
10
+ listAccounts,
11
+ removeAccount,
12
+ setActiveAccount
13
+ } from './accounts.js'
14
+ import { containsSensitiveKey, redactSensitive } from './security.js'
15
+ import {
16
+ getApiMetadata,
17
+ getMethodMetadata,
18
+ getSdk,
19
+ resetSdkConfig,
20
+ searchMethods,
21
+ summarizeMetadata
22
+ } from './sdk.js'
23
+
24
+ const VERSION = '26.5.1'
25
+
26
+ const server = new McpServer(
27
+ {
28
+ name: 'apifm-admin-mcp',
29
+ version: VERSION
30
+ },
31
+ {
32
+ instructions: [
33
+ 'Use this MCP server to operate APIFM admin APIs through the apifm-admin SDK.',
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
+ 'Use apifm_admin_search_methods before apifm_admin_call when the exact SDK method name is unknown.'
37
+ ].join('\n')
38
+ }
39
+ )
40
+
41
+ server.registerTool(
42
+ 'apifm_admin_start_auth',
43
+ {
44
+ title: 'Start secure APIFM admin authorization',
45
+ description:
46
+ '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
+ inputSchema: z.object({
48
+ authType: z
49
+ .enum(['basic', 'x-token', 'password', 'register-email'])
50
+ .describe(
51
+ 'basic = Basic Authentication, x-token = existing admin X-Token, password = username/email + password login, register-email = create admin account by email'
52
+ ),
53
+ alias: z.string().optional().describe('Friendly local account alias, for example prod or test-shop.'),
54
+ domains: z
55
+ .record(z.string(), z.string().url())
56
+ .optional()
57
+ .describe('Optional apifm-admin domain overrides by domainKey, for example {"common":"https://common.apifm.com"}.'),
58
+ port: z.number().int().min(0).max(65535).optional().describe('Optional localhost port. 0 chooses a free port.')
59
+ })
60
+ },
61
+ async ({ authType, alias, domains, port }) => {
62
+ const session = await createAuthSession({ authType, alias, domains, port })
63
+ return toolJson({
64
+ ...session,
65
+ safety: 'Open the URL yourself and enter secrets there. Do not paste secrets into the agent chat.'
66
+ })
67
+ }
68
+ )
69
+
70
+ server.registerTool(
71
+ 'apifm_admin_accounts',
72
+ {
73
+ title: 'List APIFM admin accounts',
74
+ description: 'Lists local in-memory account aliases and the active account. Does not reveal secrets.',
75
+ inputSchema: z.object({})
76
+ },
77
+ async () =>
78
+ toolJson({
79
+ activeAlias: getActiveAlias(),
80
+ accounts: listAccounts()
81
+ })
82
+ )
83
+
84
+ server.registerTool(
85
+ 'apifm_admin_switch_account',
86
+ {
87
+ title: 'Switch APIFM admin account',
88
+ description: 'Switches the active local account alias for later API calls. Does not reveal secrets.',
89
+ inputSchema: z.object({
90
+ alias: z.string().min(1)
91
+ })
92
+ },
93
+ async ({ alias }) => toolJson({ active: setActiveAccount(alias) })
94
+ )
95
+
96
+ server.registerTool(
97
+ 'apifm_admin_remove_account',
98
+ {
99
+ title: 'Remove APIFM admin account',
100
+ description: 'Removes an in-memory account alias and its secrets from this MCP server process.',
101
+ inputSchema: z.object({
102
+ alias: z.string().min(1)
103
+ })
104
+ },
105
+ async ({ alias }) =>
106
+ toolJson({
107
+ removed: removeAccount(alias),
108
+ activeAlias: getActiveAlias()
109
+ })
110
+ )
111
+
112
+ server.registerTool(
113
+ 'apifm_admin_search_methods',
114
+ {
115
+ title: 'Search apifm-admin SDK methods',
116
+ description:
117
+ 'Searches the currently installed apifm-admin SDK method metadata and runtime exports. Use Chinese or English keywords such as 用户列表, login, register, order, userList.',
118
+ inputSchema: z.object({
119
+ query: z.string().optional(),
120
+ limit: z.number().int().min(1).max(100).optional()
121
+ })
122
+ },
123
+ async ({ query = '', limit = 20 }) =>
124
+ toolJson({
125
+ sdkMethodCount: getApiMetadata().length,
126
+ results: searchMethods(query, limit)
127
+ })
128
+ )
129
+
130
+ server.registerTool(
131
+ 'apifm_admin_method_info',
132
+ {
133
+ title: 'Get apifm-admin SDK method info',
134
+ description: 'Returns metadata for one SDK method, including route, HTTP method, parameters, and return example.',
135
+ inputSchema: z.object({
136
+ methodName: z.string().min(1)
137
+ })
138
+ },
139
+ async ({ methodName }) => {
140
+ const meta = getMethodMetadata(methodName)
141
+ if (!meta) {
142
+ return toolError(`Unknown SDK method: ${methodName}`)
143
+ }
144
+ return toolJson(summarizeMetadata(meta))
145
+ }
146
+ )
147
+
148
+ server.registerTool(
149
+ 'apifm_admin_call',
150
+ {
151
+ title: 'Call an APIFM admin API',
152
+ description:
153
+ 'Calls any non-sensitive apifm-admin SDK method by name using the active or selected local account. Payload and headers must not contain passwords, tokens, Basic credentials, secrets, or API keys.',
154
+ inputSchema: z.object({
155
+ methodName: z.string().min(1).describe('apifm-admin SDK method name, for example userList.'),
156
+ payload: z.record(z.string(), z.any()).optional().describe('Non-sensitive SDK payload.'),
157
+ alias: z.string().optional().describe('Optional local account alias. Defaults to the active account.'),
158
+ domains: z
159
+ .record(z.string(), z.string().url())
160
+ .optional()
161
+ .describe('Optional per-call domain overrides by apifm-admin domainKey.'),
162
+ headers: z
163
+ .record(z.string(), z.string())
164
+ .optional()
165
+ .describe('Optional non-sensitive extra headers. Authorization and token headers are rejected.')
166
+ })
167
+ },
168
+ async ({ methodName, payload = {}, alias, domains, headers = {} }) => {
169
+ const sensitivePayloadPath = containsSensitiveKey(payload)
170
+ if (sensitivePayloadPath) {
171
+ return toolError(
172
+ `Refusing to call ${methodName}: payload contains a sensitive field at "${sensitivePayloadPath}". Use apifm_admin_start_auth for secrets.`
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
+ )
187
+ }
188
+
189
+ const sdk = getSdk()
190
+ if (typeof sdk[methodName] !== 'function') {
191
+ return toolError(`apifm-admin does not expose a function named ${methodName}. Search methods first.`)
192
+ }
193
+
194
+ resetSdkConfig()
195
+ const allDomains = { ...(account.domains || {}), ...(domains || {}) }
196
+ const sdkHeaders = { ...headers }
197
+ if (account.basicAuth) {
198
+ sdkHeaders.Authorization = account.basicAuth
199
+ }
200
+
201
+ sdk.setConfig({
202
+ token: account.token || '',
203
+ headers: sdkHeaders,
204
+ domains: allDomains
205
+ })
206
+
207
+ const result = await sdk[methodName](payload)
208
+ return toolJson({
209
+ methodName,
210
+ accountAlias: account.alias,
211
+ metadata: getMethodMetadata(methodName) ? summarizeMetadata(getMethodMetadata(methodName)) : null,
212
+ result: redactSensitive(result)
213
+ })
214
+ }
215
+ )
216
+
217
+ server.registerResource(
218
+ 'apifm-admin-methods',
219
+ 'apifm-admin://methods',
220
+ {
221
+ title: 'apifm-admin SDK methods',
222
+ description: 'Current runtime method metadata discovered from the installed apifm-admin SDK.',
223
+ mimeType: 'application/json'
224
+ },
225
+ async (uri) => ({
226
+ contents: [
227
+ {
228
+ uri: uri.href,
229
+ mimeType: 'application/json',
230
+ text: JSON.stringify(getApiMetadata().map(summarizeMetadata), null, 2)
231
+ }
232
+ ]
233
+ })
234
+ )
235
+
236
+ function toolJson(data) {
237
+ return {
238
+ content: [
239
+ {
240
+ type: 'text',
241
+ text: JSON.stringify(data, null, 2)
242
+ }
243
+ ],
244
+ structuredContent: data
245
+ }
246
+ }
247
+
248
+ function toolError(message) {
249
+ return {
250
+ isError: true,
251
+ content: [
252
+ {
253
+ type: 'text',
254
+ text: message
255
+ }
256
+ ],
257
+ structuredContent: { error: message }
258
+ }
259
+ }
260
+
261
+ const transport = new StdioServerTransport()
262
+ await server.connect(transport)
package/src/sdk.js ADDED
@@ -0,0 +1,130 @@
1
+ import apifmAdmin, { API_METADATA as EXPORTED_METADATA } from 'apifm-admin'
2
+
3
+ const RESERVED_METHODS = new Set([
4
+ 'getConfig',
5
+ 'setConfig',
6
+ 'setToken',
7
+ 'setTokenGetter',
8
+ 'setHeader',
9
+ 'setHeaders',
10
+ 'setDomain',
11
+ 'setDomains',
12
+ 'setRequestAdapter',
13
+ 'getApiMetadata'
14
+ ])
15
+
16
+ export function getSdk() {
17
+ return apifmAdmin?.default || apifmAdmin
18
+ }
19
+
20
+ export function getApiMetadata() {
21
+ const sdk = getSdk()
22
+ const metadata =
23
+ (typeof sdk.getApiMetadata === 'function' && sdk.getApiMetadata()) ||
24
+ EXPORTED_METADATA ||
25
+ []
26
+
27
+ const byName = new Map()
28
+ for (const item of metadata) {
29
+ if (item?.methodName) {
30
+ byName.set(item.methodName, item)
31
+ }
32
+ }
33
+
34
+ for (const [name, value] of Object.entries(sdk)) {
35
+ if (typeof value !== 'function' || RESERVED_METHODS.has(name) || byName.has(name)) {
36
+ continue
37
+ }
38
+ byName.set(name, {
39
+ methodName: name,
40
+ summary: 'SDK method discovered at runtime',
41
+ notes: '',
42
+ params: [],
43
+ usage: '',
44
+ route: '',
45
+ httpMethod: '',
46
+ domainKey: '',
47
+ baseUrl: '',
48
+ runtimeOnly: true
49
+ })
50
+ }
51
+
52
+ return [...byName.values()].sort((a, b) => a.methodName.localeCompare(b.methodName))
53
+ }
54
+
55
+ export function getMethodMetadata(methodName) {
56
+ return getApiMetadata().find((item) => item.methodName === methodName) || null
57
+ }
58
+
59
+ export function resetSdkConfig() {
60
+ const sdk = getSdk()
61
+ const config = typeof sdk.getConfig === 'function' ? sdk.getConfig() : {}
62
+
63
+ for (const key of Object.keys(config.headers || {})) {
64
+ sdk.setHeader(key, undefined)
65
+ }
66
+
67
+ for (const key of Object.keys(config.domains || {})) {
68
+ sdk.setDomain(key, undefined)
69
+ }
70
+
71
+ sdk.setToken('')
72
+ sdk.setTokenGetter(null)
73
+ }
74
+
75
+ export function searchMethods(query = '', limit = 20) {
76
+ const normalizedQuery = String(query || '').trim().toLowerCase()
77
+ const words = normalizedQuery.split(/\s+/).filter(Boolean)
78
+
79
+ const scored = getApiMetadata().map((meta) => {
80
+ const haystack = [
81
+ meta.methodName,
82
+ meta.rawMethodName,
83
+ meta.summary,
84
+ meta.notes,
85
+ meta.route,
86
+ meta.usage,
87
+ ...(meta.params || []).flatMap((param) => [param.name, param.type, param.description])
88
+ ]
89
+ .filter(Boolean)
90
+ .join(' ')
91
+ .toLowerCase()
92
+
93
+ if (!words.length) {
94
+ return { meta, score: 1 }
95
+ }
96
+
97
+ let score = 0
98
+ for (const word of words) {
99
+ if (meta.methodName?.toLowerCase() === word) score += 50
100
+ if (meta.methodName?.toLowerCase().includes(word)) score += 20
101
+ if (haystack.includes(word)) score += 5
102
+ }
103
+
104
+ return { meta, score }
105
+ })
106
+
107
+ return scored
108
+ .filter((item) => item.score > 0)
109
+ .sort((a, b) => b.score - a.score || a.meta.methodName.localeCompare(b.meta.methodName))
110
+ .slice(0, Math.max(1, Math.min(Number(limit) || 20, 100)))
111
+ .map((item) => summarizeMetadata(item.meta))
112
+ }
113
+
114
+ export function summarizeMetadata(meta) {
115
+ return {
116
+ methodName: meta.methodName,
117
+ summary: meta.summary || '',
118
+ notes: meta.notes || '',
119
+ route: meta.route || '',
120
+ httpMethod: meta.httpMethod || '',
121
+ domainKey: meta.domainKey || '',
122
+ params: (meta.params || []).map((param) => ({
123
+ name: param.name,
124
+ type: param.type,
125
+ description: param.description
126
+ })),
127
+ usage: meta.usage || '',
128
+ runtimeOnly: Boolean(meta.runtimeOnly)
129
+ }
130
+ }
@@ -0,0 +1,79 @@
1
+ export const SENSITIVE_KEY_PATTERN =
2
+ /(^|[_\-.])(password|passwd|pwd|secret|token|x-token|xtoken|authorization|auth|basic|apikey|api-key|key|merchantkey|merchant_key|access[_\-.]?token|refresh[_\-.]?token)($|[_\-.])/i
3
+
4
+ export function containsSensitiveKey(value, path = []) {
5
+ if (!value || typeof value !== 'object') {
6
+ return null
7
+ }
8
+
9
+ if (Array.isArray(value)) {
10
+ for (let index = 0; index < value.length; index += 1) {
11
+ const found = containsSensitiveKey(value[index], [...path, String(index)])
12
+ if (found) return found
13
+ }
14
+ return null
15
+ }
16
+
17
+ for (const [key, child] of Object.entries(value)) {
18
+ const nextPath = [...path, key]
19
+ if (isSensitiveKey(key)) {
20
+ return nextPath.join('.')
21
+ }
22
+ const found = containsSensitiveKey(child, nextPath)
23
+ if (found) return found
24
+ }
25
+
26
+ return null
27
+ }
28
+
29
+ export function redactSensitive(value) {
30
+ if (!value || typeof value !== 'object') {
31
+ return value
32
+ }
33
+
34
+ if (Array.isArray(value)) {
35
+ return value.map((item) => redactSensitive(item))
36
+ }
37
+
38
+ return Object.fromEntries(
39
+ Object.entries(value).map(([key, child]) => [
40
+ key,
41
+ isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(child)
42
+ ])
43
+ )
44
+ }
45
+
46
+ export function isSensitiveKey(key) {
47
+ const raw = String(key || '')
48
+ const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, '')
49
+ return (
50
+ SENSITIVE_KEY_PATTERN.test(raw) ||
51
+ [
52
+ 'pwd',
53
+ 'password',
54
+ 'passwd',
55
+ 'secret',
56
+ 'token',
57
+ 'xtoken',
58
+ 'authorization',
59
+ 'auth',
60
+ 'basic',
61
+ 'apikey',
62
+ 'merchantkey',
63
+ 'accesstoken',
64
+ 'refreshtoken'
65
+ ].some((word) => compact === word || compact.endsWith(word))
66
+ )
67
+ }
68
+
69
+ export function createPublicAccount(account) {
70
+ return {
71
+ alias: account.alias,
72
+ authType: account.authType,
73
+ createdAt: account.createdAt,
74
+ updatedAt: account.updatedAt,
75
+ domains: Object.keys(account.domains || {}),
76
+ hasToken: Boolean(account.token),
77
+ hasBasicAuth: Boolean(account.basicAuth)
78
+ }
79
+ }
@@ -0,0 +1,60 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
3
+
4
+ const transport = new StdioClientTransport({
5
+ command: process.execPath,
6
+ args: ['./src/index.js'],
7
+ cwd: process.cwd(),
8
+ stderr: 'pipe'
9
+ })
10
+
11
+ const client = new Client({
12
+ name: 'apifm-admin-mcp-self-check',
13
+ version: '1.0.0'
14
+ })
15
+
16
+ try {
17
+ await client.connect(transport)
18
+
19
+ const tools = await client.listTools()
20
+ const names = tools.tools.map((tool) => tool.name)
21
+ const requiredTools = [
22
+ 'apifm_admin_start_auth',
23
+ 'apifm_admin_accounts',
24
+ 'apifm_admin_search_methods',
25
+ 'apifm_admin_method_info',
26
+ 'apifm_admin_call'
27
+ ]
28
+ for (const name of requiredTools) {
29
+ if (!names.includes(name)) {
30
+ throw new Error(`Missing tool: ${name}`)
31
+ }
32
+ }
33
+
34
+ const search = await client.callTool({
35
+ name: 'apifm_admin_search_methods',
36
+ arguments: { query: '用户 列表', limit: 5 }
37
+ })
38
+ const searchData = JSON.parse(search.content[0].text)
39
+ if (!searchData.sdkMethodCount || !Array.isArray(searchData.results)) {
40
+ throw new Error('Method search did not return SDK metadata.')
41
+ }
42
+
43
+ const guard = await client.callTool({
44
+ name: 'apifm_admin_call',
45
+ arguments: {
46
+ methodName: 'loginAdminEmail',
47
+ payload: { email: 'demo@example.com', pwd: 'secret' }
48
+ }
49
+ })
50
+ if (!guard.isError) {
51
+ throw new Error('Sensitive payload guard did not reject password fields.')
52
+ }
53
+
54
+ await client.close()
55
+ console.log(`self-check passed: ${names.length} tools, ${searchData.sdkMethodCount} SDK methods discovered`)
56
+ } catch (error) {
57
+ await client.close().catch(() => {})
58
+ console.error(error)
59
+ process.exitCode = 1
60
+ }