@hizml/yapi-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the yapi-mcp authors
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,83 @@
1
+ # yapi-mcp
2
+
3
+ > English | [中文](README.zh-CN.md)
4
+
5
+ > A zero-dependency [YApi](https://github.com/YMFE/yapi) [MCP](https://modelcontextprotocol.io) server that exposes YApi's API-management capabilities to Claude Code and any MCP client.
6
+
7
+ ## Why this exists
8
+
9
+ The npm package `@yogeliu/yapi-mcp-server` has two defects that make it **completely unusable**:
10
+
11
+ 1. **Broken `inputSchema` serialization** — it returns the Zod schema *object* itself as `inputSchema`. After `JSON.stringify` it becomes `{"_def":...}`, an invalid structure. MCP clients reject it with `tools fetch failed`, so none of the tools ever load.
12
+ 2. **Wrong interface-list strategy** — YApi's `/api/interface/list` **ignores `catid`** on most versions and returns only the first page by default. The original package's "iterate categories" approach causes massive duplication and misses most interfaces; it also reads the wrong id field (`_id`).
13
+
14
+ This project is rewritten from scratch with **zero runtime dependencies** (only Node ≥18 built-in `fetch`), hand-written valid JSON Schemas, and project-level pagination with dedup — fixing all of the above.
15
+
16
+ ## Tools
17
+
18
+ | Tool | Description |
19
+ |---|---|
20
+ | `yapi_list_projects` | List the configured project (id / name / desc) |
21
+ | `yapi_get_categories` | List project categories and the APIs under each |
22
+ | `yapi_search_apis` | Search APIs by keyword (title / path), optional method filter |
23
+ | `yapi_get_api_details` | Full detail of one API (params / headers / body / response) |
24
+ | `yapi_save_api` | Create or update an API (with `api_id` → update; without → create) |
25
+
26
+ ## Install
27
+
28
+ ### Option 1: npx (recommended)
29
+
30
+ No install needed — use it directly in your MCP config:
31
+ ```json
32
+ { "command": "npx", "args": ["-y", "@hizml/yapi-mcp"] }
33
+ ```
34
+
35
+ ### Option 2: clone
36
+ ```bash
37
+ git clone https://github.com/hizml/yapi-mcp.git
38
+ ```
39
+ Point the config at the local file:
40
+ ```json
41
+ { "command": "node", "args": ["/absolute/path/to/yapi-mcp/yapi-mcp.mjs"] }
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ Two environment variables:
47
+ - `YAPI_BASE_URL` — YApi host, e.g. `http://yapi.example.com`
48
+ - `YAPI_TOKEN` — format `projectId:tokenValue`, from the YApi project "Settings → token"
49
+
50
+ ### Claude Code (`~/.claude.json`)
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "yapi": {
55
+ "type": "stdio",
56
+ "command": "npx",
57
+ "args": ["-y", "@hizml/yapi-mcp"],
58
+ "env": {
59
+ "YAPI_BASE_URL": "http://your-yapi-host",
60
+ "YAPI_TOKEN": "227:your_token_here"
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+ Full example: [`examples/claude-code-config.json`](examples/claude-code-config.json).
67
+
68
+ ## Features
69
+
70
+ - **Zero dependencies** — pure Node ESM, only Node ≥18 built-in `fetch`
71
+ - **Valid JSON Schema** — every `inputSchema` is hand-written standard JSON Schema, so clients validate it fine
72
+ - **Full pagination** — project-level pagination + dedup, no missing or duplicate APIs
73
+ - **Robust errors** — param errors, timeouts, and YApi `errcode` all become `isError` messages; the process never crashes
74
+ - **Debuggable** — set `DEBUG=1` to emit logs to stderr
75
+
76
+ ## Known limitations
77
+
78
+ - Currently **single-token (single-project)**; for multiple projects, run multiple instances
79
+ - YApi's `/api/interface/list` `total` field is unreliable, so this tool stops paginating when a page returns fewer than the page size
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,83 @@
1
+ # yapi-mcp
2
+
3
+ > [English](README.md) | 中文
4
+
5
+ > 零依赖的 [YApi](https://github.com/YMFE/yapi) [MCP](https://modelcontextprotocol.io) server,为 Claude Code 及任意 MCP 客户端暴露 YApi 的接口管理能力。
6
+
7
+ ## 为什么有这个
8
+
9
+ npm 上的 `@yogeliu/yapi-mcp-server` 存在两个导致**完全不可用**的缺陷:
10
+
11
+ 1. **`inputSchema` 序列化错误**:把 Zod schema 对象本身当作 `inputSchema` 返回,`JSON.stringify` 后变成 `{"_def":...}` 非法结构,MCP 客户端校验失败、报 `tools fetch failed`,所有工具都加载不出来。
12
+ 2. **接口列表策略错误**:YApi 的 `/api/interface/list` 在多数版本上**忽略 `catid`** 且默认仅返回前若干条,原包「遍历分类」的策略会让结果大量重复并漏掉绝大多数接口;同时接口 id 字段(`_id`)取错。
13
+
14
+ 本项目从零重写,**零运行时依赖**(仅用 Node ≥18 内置 `fetch`),手写合法 JSON Schema,并以 project 级分页 + 去重拉取接口,彻底修复上述问题。
15
+
16
+ ## 提供的工具
17
+
18
+ | 工具 | 说明 |
19
+ |---|---|
20
+ | `yapi_list_projects` | 列出 token 配置的项目信息(id / 名称 / 描述) |
21
+ | `yapi_get_categories` | 获取项目分类及每个分类下的接口 |
22
+ | `yapi_search_apis` | 按关键词(title / path)搜索接口,可选 method 过滤 |
23
+ | `yapi_get_api_details` | 获取单个接口完整详情(参数 / 请求头 / 请求体 / 响应体等) |
24
+ | `yapi_save_api` | 创建或更新接口(带 `api_id` 走更新,否则创建) |
25
+
26
+ ## 安装
27
+
28
+ ### 方式一:npx 直接跑(推荐)
29
+
30
+ 无需安装,MCP 配置里写:
31
+ ```json
32
+ { "command": "npx", "args": ["-y", "@hizml/yapi-mcp"] }
33
+ ```
34
+
35
+ ### 方式二:克隆源码
36
+ ```bash
37
+ git clone https://github.com/hizml/yapi-mcp.git
38
+ ```
39
+ 配置里指向本地文件:
40
+ ```json
41
+ { "command": "node", "args": ["/absolute/path/to/yapi-mcp/yapi-mcp.mjs"] }
42
+ ```
43
+
44
+ ## 配置
45
+
46
+ 两个环境变量:
47
+ - `YAPI_BASE_URL`:YApi 地址,如 `http://yapi.example.com`
48
+ - `YAPI_TOKEN`:格式 `projectId:tokenValue`,在 YApi 项目「设置 → token 配置」获取
49
+
50
+ ### Claude Code(`~/.claude.json`)
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "yapi": {
55
+ "type": "stdio",
56
+ "command": "npx",
57
+ "args": ["-y", "@hizml/yapi-mcp"],
58
+ "env": {
59
+ "YAPI_BASE_URL": "http://your-yapi-host",
60
+ "YAPI_TOKEN": "227:your_token_here"
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+ 完整示例见 [`examples/claude-code-config.json`](examples/claude-code-config.json)。
67
+
68
+ ## 特性
69
+
70
+ - **零依赖**:纯 Node ESM,仅依赖 Node ≥18 内置 `fetch`
71
+ - **合法 JSON Schema**:所有 `inputSchema` 手写为标准 JSON Schema,客户端校验通过
72
+ - **分页拉全**:project 级分页 + 去重,接口列表准确无遗漏
73
+ - **健壮错误处理**:参数错误、网络超时、YApi `errcode` 均转为中文 `isError` 提示,进程不崩溃
74
+ - **可调试**:设置 `DEBUG=1` 向 stderr 输出日志
75
+
76
+ ## 已知限制
77
+
78
+ - 当前为**单 token(单项目)**模式;多项目请配置多个实例
79
+ - YApi `/api/interface/list` 的 `total` 字段不可靠,本工具以「分页拉到不足一页」作为终止条件
80
+
81
+ ## License
82
+
83
+ MIT
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@hizml/yapi-mcp",
3
+ "version": "1.0.0",
4
+ "description": "零依赖 YApi MCP server:暴露项目/分类/接口查询与写入工具,修复原包 inputSchema 与接口列表分页问题。",
5
+ "type": "module",
6
+ "main": "yapi-mcp.mjs",
7
+ "bin": {
8
+ "yapi-mcp": "yapi-mcp.mjs"
9
+ },
10
+ "files": [
11
+ "yapi-mcp.mjs",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "start": "node yapi-mcp.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "yapi",
23
+ "mcp",
24
+ "model-context-protocol",
25
+ "claude",
26
+ "claude-code",
27
+ "api-management",
28
+ "openapi",
29
+ "stdio"
30
+ ],
31
+ "license": "MIT",
32
+ "author": "hizml",
33
+ "homepage": "https://github.com/hizml/yapi-mcp#readme",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/hizml/yapi-mcp.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/hizml/yapi-mcp/issues"
40
+ }
41
+ }
package/yapi-mcp.mjs ADDED
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env node
2
+ // yapi-mcp.mjs — 零依赖 YApi MCP Server(stdio / NDJSON)
3
+ //
4
+ // 用途:为 Claude Code 暴露 YApi 的项目/分类/接口查询与写入能力。
5
+ // 环境:YAPI_BASE_URL(如 http://your-yapi-host)、YAPI_TOKEN(格式 projectId:tokenValue)。
6
+ // 传输:标准输入/输出,每行一个 JSON-RPC 2.0 消息;任何日志只写 stderr,绝不污染 stdout。
7
+ //
8
+ // 本文件替代有 bug 的 npm 包 @yogeliu/yapi-mcp-server:该包把 Zod schema 对象
9
+ // 直接当作 inputSchema 返回,序列化后为非法结构,导致客户端 "tools fetch failed"。
10
+ // 这里所有 inputSchema 都是手写的合法 JSON Schema(plain object),从根上修复。
11
+
12
+ import readline from 'node:readline';
13
+
14
+ // ───────────────────────── 常量 ─────────────────────────
15
+ const PROTOCOL_VERSION = '2024-11-05';
16
+ const SERVER_INFO = { name: 'yapi-mcp', version: '1.0.0' };
17
+ const REQUEST_TIMEOUT_MS = 15000;
18
+ const MAX_MATCHES = 50;
19
+ const MAX_TEXT_BYTES = 100 * 1024;
20
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
21
+
22
+ // ───────────────────────── 1. 配置层 ─────────────────────────
23
+ function parseConfig() {
24
+ const baseUrl = (process.env.YAPI_BASE_URL || '').replace(/\/+$/, '');
25
+ const token = process.env.YAPI_TOKEN || '';
26
+ const [projectIdStr, tokenValue] = token.split(':');
27
+ const defaultProjectId = Number(projectIdStr);
28
+ const isValid = baseUrl && tokenValue && Number.isFinite(defaultProjectId);
29
+ if (!isValid) {
30
+ console.error('[yapi-mcp] 配置无效:需设置 YAPI_BASE_URL 与 YAPI_TOKEN(格式 projectId:tokenValue)');
31
+ process.exit(1);
32
+ }
33
+ return { baseUrl, tokenValue, defaultProjectId };
34
+ }
35
+
36
+ // ───────────────────────── 2. HTTP 层 ─────────────────────────
37
+ function wrapFetchError(err) {
38
+ if (err.name === 'TimeoutError' || err.name === 'AbortError') {
39
+ return new Error(`YApi 请求超时(${REQUEST_TIMEOUT_MS / 1000}s)`);
40
+ }
41
+ return new Error(`YApi 网络错误: ${err.message}`);
42
+ }
43
+
44
+ async function unwrap(res) {
45
+ if (!res.ok) throw new Error(`YApi HTTP ${res.status}`);
46
+ let payload;
47
+ try {
48
+ payload = await res.json();
49
+ } catch {
50
+ throw new Error('YApi 返回非 JSON 响应');
51
+ }
52
+ if (payload.errcode !== 0) {
53
+ throw new Error(`YApi 错误 ${payload.errcode}: ${payload.errmsg || '未知错误'}`);
54
+ }
55
+ return payload.data;
56
+ }
57
+
58
+ function buildUrl(cfg, endpoint, params) {
59
+ const url = new URL(cfg.baseUrl + endpoint);
60
+ url.searchParams.set('token', cfg.tokenValue);
61
+ for (const [key, value] of Object.entries(params)) {
62
+ if (value !== undefined && value !== null) url.searchParams.set(key, value);
63
+ }
64
+ return url;
65
+ }
66
+
67
+ async function yapiGet(cfg, endpoint, params = {}) {
68
+ try {
69
+ const res = await fetch(buildUrl(cfg, endpoint, params), {
70
+ headers: { accept: 'application/json' },
71
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
72
+ });
73
+ return await unwrap(res);
74
+ } catch (err) {
75
+ throw err.message.startsWith('YApi') ? err : wrapFetchError(err);
76
+ }
77
+ }
78
+
79
+ async function yapiPost(cfg, endpoint, body = {}) {
80
+ try {
81
+ const res = await fetch(cfg.baseUrl + endpoint, {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
84
+ body: JSON.stringify({ ...body, token: cfg.tokenValue }),
85
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
86
+ });
87
+ return await unwrap(res);
88
+ } catch (err) {
89
+ throw err.message.startsWith('YApi') ? err : wrapFetchError(err);
90
+ }
91
+ }
92
+
93
+ // ───────────────────────── 3. 参数校验与工具函数 ─────────────────────────
94
+ function throwParamError(message) {
95
+ const err = new Error(message);
96
+ err.isParamError = true;
97
+ throw err;
98
+ }
99
+
100
+ function requireArgs(args, ...keys) {
101
+ const missing = keys.filter((k) => args[k] === undefined || args[k] === null);
102
+ if (missing.length > 0) throwParamError(`缺少必填参数: ${missing.join(', ')}`);
103
+ }
104
+
105
+ function toInt(value) {
106
+ const num = Number(value);
107
+ return Number.isInteger(num) ? num : NaN;
108
+ }
109
+
110
+ function toApiSummary(api) {
111
+ return {
112
+ id: api._id ?? api.id,
113
+ project_id: api.project_id,
114
+ catid: api.catid,
115
+ title: api.title,
116
+ path: api.path,
117
+ method: api.method,
118
+ };
119
+ }
120
+
121
+ function isKeywordMatch(api, keyword) {
122
+ return (api.title || '').toLowerCase().includes(keyword)
123
+ || (api.path || '').toLowerCase().includes(keyword);
124
+ }
125
+
126
+ // yapi 的请求体/响应体结构以字符串存储,模型若误传对象则序列化为字符串
127
+ function normalizeBodyFields(args) {
128
+ const out = { ...args };
129
+ for (const field of ['req_body_other', 'res_body']) {
130
+ if (out[field] !== undefined && typeof out[field] === 'object') {
131
+ out[field] = JSON.stringify(out[field]);
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function truncateText(text) {
138
+ const bytes = Buffer.byteLength(text, 'utf8');
139
+ if (bytes <= MAX_TEXT_BYTES) return text;
140
+ return `${text.slice(0, MAX_TEXT_BYTES)}\n... (已截断,完整大小 ${bytes} 字节)`;
141
+ }
142
+
143
+ // ───────────────────────── 4. 工具实现 ─────────────────────────
144
+ async function listProjects(args, cfg) {
145
+ const data = await yapiGet(cfg, '/api/project/get', { id: cfg.defaultProjectId });
146
+ return { projects: [{ id: data._id, name: data.name, desc: data.desc }] };
147
+ }
148
+
149
+ const LIST_PAGE_SIZE = 100;
150
+
151
+ // 该 yapi 的 /api/interface/list 忽略 catid、默认仅返回前若干条,
152
+ // 必须以 project_id + page + limit 分页拉取才能取全;按 id 去重作保险。
153
+ async function fetchAllInterfaces(cfg, projectId) {
154
+ const all = [];
155
+ const seen = new Set();
156
+ for (let page = 1; page <= 50; page++) {
157
+ const data = await yapiGet(cfg, '/api/interface/list', { project_id: projectId, page, limit: LIST_PAGE_SIZE });
158
+ const list = data.list || [];
159
+ for (const api of list) {
160
+ const summary = toApiSummary(api);
161
+ if (!seen.has(summary.id)) { seen.add(summary.id); all.push(summary); }
162
+ }
163
+ if (list.length < LIST_PAGE_SIZE) break;
164
+ }
165
+ return all;
166
+ }
167
+
168
+ async function getCategories(args, cfg) {
169
+ requireArgs(args, 'project_id');
170
+ const projectId = toInt(args.project_id);
171
+ if (Number.isNaN(projectId)) throwParamError('project_id 必须为整数');
172
+ const categories = await yapiGet(cfg, '/api/interface/getCatMenu', { project_id: projectId });
173
+ const apis = await fetchAllInterfaces(cfg, projectId);
174
+ const byCat = new Map();
175
+ for (const api of apis) {
176
+ if (!byCat.has(api.catid)) byCat.set(api.catid, []);
177
+ byCat.get(api.catid).push(api);
178
+ }
179
+ const result = categories.map((cat) => ({
180
+ id: cat._id,
181
+ name: cat.name,
182
+ count: (byCat.get(cat._id) || []).length,
183
+ apis: byCat.get(cat._id) || [],
184
+ }));
185
+ return { categories: result };
186
+ }
187
+
188
+ async function searchApis(args, cfg) {
189
+ requireArgs(args, 'query');
190
+ const projectId = args.project_id === undefined ? cfg.defaultProjectId : toInt(args.project_id);
191
+ if (Number.isNaN(projectId)) throwParamError('project_id 必须为整数');
192
+ const keyword = String(args.query).toLowerCase();
193
+ const method = args.method ? String(args.method).toUpperCase() : null;
194
+ const matches = (await fetchAllInterfaces(cfg, projectId))
195
+ .filter((api) => isKeywordMatch(api, keyword) && (!method || api.method === method));
196
+ return {
197
+ project_id: projectId,
198
+ query: args.query,
199
+ method,
200
+ total: matches.length,
201
+ matches: matches.slice(0, MAX_MATCHES),
202
+ truncated: matches.length > MAX_MATCHES,
203
+ };
204
+ }
205
+
206
+ async function getApiDetails(args, cfg) {
207
+ requireArgs(args, 'api_id');
208
+ const apiId = toInt(args.api_id);
209
+ if (Number.isNaN(apiId)) throwParamError('api_id 必须为整数');
210
+ return await yapiGet(cfg, '/api/interface/get', { id: apiId });
211
+ }
212
+
213
+ async function saveApi(args, cfg) {
214
+ requireArgs(args, 'project_id', 'catid');
215
+ const projectId = toInt(args.project_id);
216
+ const catid = toInt(args.catid);
217
+ if (Number.isNaN(projectId) || Number.isNaN(catid)) throwParamError('project_id 与 catid 必须为整数');
218
+ const { api_id, project_id: _pid, catid: _cid, ...rest } = normalizeBodyFields(args);
219
+ const isUpdate = api_id !== undefined;
220
+ const payload = { ...rest, project_id: projectId, catid };
221
+ if (isUpdate) payload.id = toInt(api_id);
222
+ const endpoint = isUpdate ? '/api/interface/up' : '/api/interface/add';
223
+ const data = await yapiPost(cfg, endpoint, payload);
224
+ return { success: true, action: isUpdate ? 'updated' : 'created', api_id: isUpdate ? payload.id : data?._id };
225
+ }
226
+
227
+ // ───────────────────────── 5. 工具表(inputSchema 为合法 JSON Schema) ─────────────────────────
228
+ const TOOLS = [
229
+ {
230
+ name: 'yapi_list_projects',
231
+ description: '列出当前 token 配置的 YApi 项目信息(项目 ID、名称、描述)。无参数。',
232
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
233
+ handler: listProjects,
234
+ },
235
+ {
236
+ name: 'yapi_get_categories',
237
+ description: '获取指定项目的接口分类列表(分类 ID、名称,以及每个分类下的接口概要)。',
238
+ inputSchema: {
239
+ type: 'object',
240
+ properties: {
241
+ project_id: { type: 'integer', description: '项目 ID。省略时使用 token 中的 projectId。' },
242
+ },
243
+ required: ['project_id'],
244
+ additionalProperties: false,
245
+ },
246
+ handler: getCategories,
247
+ },
248
+ {
249
+ name: 'yapi_search_apis',
250
+ description: '在项目内按关键词搜索接口(匹配 title/path,可选按 method 过滤),返回接口列表。',
251
+ inputSchema: {
252
+ type: 'object',
253
+ properties: {
254
+ query: { type: 'string', description: '搜索关键词,按子串匹配接口 title 或 path(不区分大小写)。' },
255
+ project_id: { type: 'integer', description: '项目 ID。省略时使用 token 中的 projectId。' },
256
+ method: { type: 'string', enum: HTTP_METHODS, description: '可选。按 HTTP 方法过滤。' },
257
+ },
258
+ required: ['query'],
259
+ additionalProperties: false,
260
+ },
261
+ handler: searchApis,
262
+ },
263
+ {
264
+ name: 'yapi_get_api_details',
265
+ description: '获取接口详细信息(请求参数、请求头、请求体类型与结构、响应体等)。',
266
+ inputSchema: {
267
+ type: 'object',
268
+ properties: {
269
+ api_id: { type: 'integer', description: '接口 ID(可由 search_apis 或 get_categories 结果获得)。' },
270
+ },
271
+ required: ['api_id'],
272
+ additionalProperties: false,
273
+ },
274
+ handler: getApiDetails,
275
+ },
276
+ {
277
+ name: 'yapi_save_api',
278
+ description: '创建或更新 YApi 接口。带 api_id 走更新(/api/interface/up),不带走创建(/api/interface/add)。至少需提供 project_id 与 catid。',
279
+ inputSchema: {
280
+ type: 'object',
281
+ properties: {
282
+ api_id: { type: 'integer', description: '要更新的接口 ID。提供时为更新;省略时为创建。' },
283
+ project_id: { type: 'integer', description: '目标项目 ID。' },
284
+ catid: { type: 'integer', description: '目标分类 ID。' },
285
+ title: { type: 'string', description: '接口标题。' },
286
+ path: { type: 'string', description: '接口路径,例如 /api/foo/bar。' },
287
+ method: { type: 'string', enum: HTTP_METHODS, description: 'HTTP 方法。' },
288
+ desc: { type: 'string', description: '接口描述。' },
289
+ req_query: {
290
+ type: 'array',
291
+ description: 'query 参数列表。',
292
+ items: {
293
+ type: 'object',
294
+ properties: {
295
+ name: { type: 'string' },
296
+ required: { type: 'string', enum: ['1', '0'], description: '是否必需:"1" 或 "0"。' },
297
+ desc: { type: 'string' },
298
+ example: { type: 'string' },
299
+ },
300
+ additionalProperties: false,
301
+ },
302
+ },
303
+ req_headers: {
304
+ type: 'array',
305
+ description: '请求头列表。',
306
+ items: {
307
+ type: 'object',
308
+ properties: {
309
+ name: { type: 'string' },
310
+ value: { type: 'string' },
311
+ required: { type: 'string', enum: ['1', '0'], description: '是否必需:"1" 或 "0"。' },
312
+ desc: { type: 'string' },
313
+ },
314
+ additionalProperties: false,
315
+ },
316
+ },
317
+ req_body_type: { type: 'string', enum: ['json', 'form', 'raw'], description: '请求体类型。' },
318
+ req_body_other: { type: 'string', description: '请求体结构,json 类型时为 JSON Schema 字符串。' },
319
+ res_body: { type: 'string', description: '响应体结构,JSON Schema 字符串。' },
320
+ },
321
+ required: ['project_id', 'catid'],
322
+ additionalProperties: false,
323
+ },
324
+ handler: saveApi,
325
+ },
326
+ ];
327
+
328
+ // ───────────────────────── 6. 协议层 ─────────────────────────
329
+ function handleInitialize() {
330
+ return { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO };
331
+ }
332
+
333
+ function handleListTools() {
334
+ return {
335
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
336
+ };
337
+ }
338
+
339
+ async function handleCallTool(req, cfg) {
340
+ const { name, arguments: args } = req.params || {};
341
+ const tool = TOOLS.find((t) => t.name === name);
342
+ if (!tool) throwParamError(`未知工具: ${name}`);
343
+ const result = await tool.handler(args || {}, cfg);
344
+ return { content: [{ type: 'text', text: truncateText(JSON.stringify(result, null, 2)) }] };
345
+ }
346
+
347
+ async function route(req, cfg) {
348
+ switch (req.method) {
349
+ case 'initialize': return handleInitialize();
350
+ case 'notifications/initialized': return undefined;
351
+ case 'tools/list': return handleListTools();
352
+ case 'tools/call': return await handleCallTool(req, cfg);
353
+ default: {
354
+ const err = new Error(`不支持的方法: ${req.method}`);
355
+ err.jsonrpcCode = -32601;
356
+ throw err;
357
+ }
358
+ }
359
+ }
360
+
361
+ // ───────────────────────── 7. JSON-RPC 框架与主循环 ─────────────────────────
362
+ function send(obj) {
363
+ process.stdout.write(`${JSON.stringify(obj)}\n`);
364
+ }
365
+
366
+ function resultResponse(id, result) {
367
+ send({ jsonrpc: '2.0', id, result });
368
+ }
369
+
370
+ function errorResponse(id, code, message) {
371
+ send({ jsonrpc: '2.0', id, error: { code, message } });
372
+ }
373
+
374
+ function log(...args) {
375
+ if (process.env.DEBUG) console.error('[yapi-mcp]', ...args);
376
+ }
377
+
378
+ function isValidRequest(req) {
379
+ return req && typeof req === 'object' && req.jsonrpc === '2.0' && typeof req.method === 'string';
380
+ }
381
+
382
+ function respondError(id, err) {
383
+ if (err.jsonrpcCode) return errorResponse(id, err.jsonrpcCode, err.message);
384
+ // 参数错误 / 业务错误 / 网络 yapi 错误 → tool 级 isError
385
+ return resultResponse(id, { isError: true, content: [{ type: 'text', text: err.message }] });
386
+ }
387
+
388
+ async function handleMessage(req, cfg) {
389
+ if (!isValidRequest(req)) return errorResponse(req?.id ?? null, -32600, 'Invalid Request');
390
+ const isNotification = req.id === undefined || req.id === null;
391
+ try {
392
+ const result = await route(req, cfg);
393
+ if (result === undefined || isNotification) return;
394
+ resultResponse(req.id, result);
395
+ } catch (err) {
396
+ if (isNotification) return log('通知处理错误:', err);
397
+ respondError(req.id, err);
398
+ }
399
+ }
400
+
401
+ async function main() {
402
+ const cfg = parseConfig();
403
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
404
+
405
+ rl.on('line', (line) => {
406
+ const trimmed = line.trim();
407
+ if (!trimmed) return;
408
+ let req;
409
+ try {
410
+ req = JSON.parse(trimmed);
411
+ } catch {
412
+ return errorResponse(null, -32700, 'Parse error');
413
+ }
414
+ handleMessage(req, cfg).catch((err) => log('未捕获消息错误:', err));
415
+ });
416
+
417
+ process.on('SIGTERM', () => process.exit(0));
418
+ process.on('SIGINT', () => process.exit(0));
419
+ process.on('uncaughtException', (err) => { log('uncaughtException:', err); process.exit(1); });
420
+ process.on('unhandledRejection', (err) => { log('unhandledRejection:', err); process.exit(1); });
421
+
422
+ log(`YApi MCP Server 已启动(项目 ${cfg.defaultProjectId}, ${cfg.baseUrl})`);
423
+ }
424
+
425
+ main();