@wllcyg001/yapi-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/yapi.js ADDED
@@ -0,0 +1,154 @@
1
+ import axios from "axios";
2
+ import { AppError } from "./errors.js";
3
+ export class YApiClient {
4
+ client;
5
+ retryCount;
6
+ constructor(options) {
7
+ this.client = axios.create({
8
+ baseURL: options.baseUrl,
9
+ timeout: options.timeoutMs,
10
+ });
11
+ this.retryCount = options.retryCount;
12
+ }
13
+ async searchInterfaces(args) {
14
+ const menuData = await this.requestWithRetry("/api/interface/list_menu", {
15
+ project_id: args.projectId,
16
+ token: args.token,
17
+ });
18
+ const flat = flattenMenuInterfaces(menuData, args.projectId);
19
+ const keyword = args.keyword.trim().toLowerCase();
20
+ const method = args.method?.toUpperCase();
21
+ const pathHint = args.pathHint?.toLowerCase();
22
+ const scored = flat
23
+ .filter((item) => {
24
+ if (method && (item.method || "").toUpperCase() !== method)
25
+ return false;
26
+ return true;
27
+ })
28
+ .map((item) => {
29
+ const title = (item.name || "").toLowerCase();
30
+ const path = (item.path || "").toLowerCase();
31
+ let score = 0;
32
+ if (title.includes(keyword))
33
+ score += 50;
34
+ if (path.includes(keyword))
35
+ score += 40;
36
+ if (pathHint && path.includes(pathHint))
37
+ score += 30;
38
+ if (title.startsWith(keyword))
39
+ score += 20;
40
+ if ((item.method || "").toUpperCase() === method)
41
+ score += 10;
42
+ return {
43
+ ...item,
44
+ score,
45
+ };
46
+ })
47
+ .filter((item) => item.score > 0)
48
+ .sort((a, b) => b.score - a.score)
49
+ .slice(0, args.limit);
50
+ return scored;
51
+ }
52
+ async getInterfaceDetail(args) {
53
+ const detail = await this.requestWithRetry("/api/interface/get", {
54
+ id: args.interfaceId,
55
+ token: args.token,
56
+ });
57
+ if (!detail || typeof detail !== "object") {
58
+ throw new AppError({
59
+ code: "INTERFACE_NOT_FOUND",
60
+ message: `Interface ${args.interfaceId} not found`,
61
+ });
62
+ }
63
+ if (!args.includeMock && "mock" in detail) {
64
+ const copy = { ...detail };
65
+ delete copy.mock;
66
+ return copy;
67
+ }
68
+ return detail;
69
+ }
70
+ async requestWithRetry(url, params) {
71
+ let attempt = 0;
72
+ const maxAttempts = Math.max(1, this.retryCount + 1);
73
+ while (attempt < maxAttempts) {
74
+ try {
75
+ const res = await this.client.get(url, { params });
76
+ const payload = res.data;
77
+ if (payload?.errcode === 0) {
78
+ return payload.data;
79
+ }
80
+ const errcode = payload?.errcode;
81
+ const errmsg = payload?.errmsg || "Unknown YApi error";
82
+ if (errcode === 40011 || errcode === 40012) {
83
+ throw new AppError({
84
+ code: "PROJECT_NOT_ACCESSIBLE",
85
+ message: errmsg,
86
+ });
87
+ }
88
+ throw new AppError({
89
+ code: "UPSTREAM_ERROR",
90
+ message: errmsg,
91
+ context: { errcode },
92
+ });
93
+ }
94
+ catch (error) {
95
+ const status = error?.response?.status;
96
+ if (status === 429) {
97
+ throw new AppError({
98
+ code: "RATE_LIMITED",
99
+ message: "YApi rate limited",
100
+ });
101
+ }
102
+ if (error instanceof AppError) {
103
+ throw error;
104
+ }
105
+ if (error?.code === "ECONNABORTED") {
106
+ if (attempt + 1 < maxAttempts) {
107
+ attempt += 1;
108
+ continue;
109
+ }
110
+ throw new AppError({
111
+ code: "UPSTREAM_TIMEOUT",
112
+ message: "YApi request timed out",
113
+ });
114
+ }
115
+ if (attempt + 1 < maxAttempts) {
116
+ attempt += 1;
117
+ continue;
118
+ }
119
+ throw new AppError({
120
+ code: "UPSTREAM_ERROR",
121
+ message: error?.message || "Failed to request YApi",
122
+ });
123
+ }
124
+ }
125
+ throw new AppError({
126
+ code: "UPSTREAM_ERROR",
127
+ message: "Unexpected request state",
128
+ });
129
+ }
130
+ }
131
+ function flattenMenuInterfaces(menuData, projectId) {
132
+ if (!Array.isArray(menuData))
133
+ return [];
134
+ const out = [];
135
+ for (const category of menuData) {
136
+ const list = category?.list;
137
+ if (!Array.isArray(list))
138
+ continue;
139
+ for (const item of list) {
140
+ const interfaceId = Number(item?._id ?? item?.id);
141
+ if (!Number.isFinite(interfaceId))
142
+ continue;
143
+ out.push({
144
+ interfaceId,
145
+ name: String(item?.title ?? item?.name ?? ""),
146
+ method: item?.method ? String(item.method) : undefined,
147
+ path: item?.path ? String(item.path) : undefined,
148
+ projectId,
149
+ score: 0,
150
+ });
151
+ }
152
+ }
153
+ return out;
154
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@wllcyg001/yapi-mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "MCP server for querying YApi interfaces by project-level token map",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "yapi-mcp-server": "dist/index.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json",
13
+ "dev": "tsx src/index.ts",
14
+ "start": "node dist/index.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.12.1",
21
+ "axios": "^1.9.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.13.9",
25
+ "tsx": "^4.19.3",
26
+ "typescript": "^5.8.2"
27
+ }
28
+ }
package/src/config.ts ADDED
@@ -0,0 +1,115 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { AppError } from "./errors.js";
5
+
6
+ export interface AppConfig {
7
+ baseUrl: string;
8
+ tokenFilePath: string;
9
+ timeoutMs: number;
10
+ retryCount: number;
11
+ fallbackToken?: string;
12
+ }
13
+
14
+ export function getConfig(): AppConfig {
15
+ const baseUrl = process.env.YAPI_BASE_URL?.trim();
16
+ if (!baseUrl) {
17
+ throw new AppError({
18
+ code: "INVALID_ARGUMENT",
19
+ message: "Missing required env: YAPI_BASE_URL",
20
+ suggestion: "Set YAPI_BASE_URL, e.g. http://10.255.30.245:3000",
21
+ });
22
+ }
23
+
24
+ return {
25
+ baseUrl,
26
+ tokenFilePath: process.env.YAPI_TOKEN_FILE?.trim() || path.join(os.homedir(), ".yapi-mcp-tokens.json"),
27
+ timeoutMs: Number(process.env.YAPI_TIMEOUT_MS || 8000),
28
+ retryCount: Number(process.env.YAPI_RETRY_COUNT || 1),
29
+ fallbackToken: process.env.YAPI_TOKEN?.trim() || undefined,
30
+ };
31
+ }
32
+
33
+ export function loadTokenMap(tokenFilePath: string): Record<string, string> {
34
+ if (!fs.existsSync(tokenFilePath)) {
35
+ return {};
36
+ }
37
+
38
+ const raw = fs.readFileSync(tokenFilePath, "utf8");
39
+ if (!raw.trim()) return {};
40
+
41
+ try {
42
+ const parsed = JSON.parse(raw) as unknown;
43
+ if (!parsed || typeof parsed !== "object") {
44
+ throw new Error("token file is not an object");
45
+ }
46
+
47
+ const result: Record<string, string> = {};
48
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
49
+ if (typeof v === "string" && v.trim()) {
50
+ result[k] = v.trim();
51
+ }
52
+ }
53
+ return result;
54
+ } catch {
55
+ throw new AppError({
56
+ code: "INVALID_ARGUMENT",
57
+ message: `Invalid token file JSON: ${tokenFilePath}`,
58
+ suggestion: "Ensure token file format is like: { \"695\": \"token_xxx\" }",
59
+ });
60
+ }
61
+ }
62
+
63
+ export function resolveProjectId(input: {
64
+ projectId?: number;
65
+ projectUrl?: string;
66
+ groupUrl?: string;
67
+ }): number {
68
+ if (typeof input.projectId === "number" && Number.isFinite(input.projectId)) {
69
+ return input.projectId;
70
+ }
71
+
72
+ if (input.projectUrl) {
73
+ const matched = input.projectUrl.match(/\/project\/(\d+)/i);
74
+ if (matched) return Number(matched[1]);
75
+
76
+ if (/\/group\/\d+/i.test(input.projectUrl)) {
77
+ throw new AppError({
78
+ code: "TOKEN_SCOPE_UNSUPPORTED",
79
+ message: "Group URL is not supported for token resolution",
80
+ suggestion: "Provide projectId or projectUrl like /project/695/interface/api",
81
+ });
82
+ }
83
+ }
84
+
85
+ if (input.groupUrl) {
86
+ throw new AppError({
87
+ code: "TOKEN_SCOPE_UNSUPPORTED",
88
+ message: "Group URL is not supported for token resolution",
89
+ suggestion: "Provide projectId or projectUrl like /project/695/interface/api",
90
+ });
91
+ }
92
+
93
+ throw new AppError({
94
+ code: "PROJECT_ID_REQUIRED",
95
+ message: "Cannot determine projectId from input",
96
+ suggestion: "Provide projectId or projectUrl",
97
+ });
98
+ }
99
+
100
+ export function resolveToken(args: {
101
+ projectId: number;
102
+ tokenMap: Record<string, string>;
103
+ fallbackToken?: string;
104
+ }): string {
105
+ const token = args.tokenMap[String(args.projectId)] || args.fallbackToken;
106
+ if (!token) {
107
+ throw new AppError({
108
+ code: "PROJECT_TOKEN_REQUIRED",
109
+ message: `Missing token for projectId=${args.projectId}`,
110
+ suggestion: `Add \"${args.projectId}\": \"<token>\" to ~/.yapi-mcp-tokens.json`,
111
+ context: { projectId: args.projectId },
112
+ });
113
+ }
114
+ return token;
115
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { ApiErrorShape, ErrorCode } from "./types.js";
2
+
3
+ export class AppError extends Error {
4
+ readonly code: ErrorCode;
5
+ readonly suggestion?: string;
6
+ readonly context?: Record<string, unknown>;
7
+
8
+ constructor(shape: ApiErrorShape) {
9
+ super(shape.message);
10
+ this.code = shape.code;
11
+ this.suggestion = shape.suggestion;
12
+ this.context = shape.context;
13
+ }
14
+
15
+ toShape(): ApiErrorShape {
16
+ return {
17
+ code: this.code,
18
+ message: this.message,
19
+ suggestion: this.suggestion,
20
+ context: this.context,
21
+ };
22
+ }
23
+ }
24
+
25
+ export function ensure(condition: unknown, shape: ApiErrorShape): asserts condition {
26
+ if (!condition) {
27
+ throw new AppError(shape);
28
+ }
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ type CallToolRequest,
8
+ } from "@modelcontextprotocol/sdk/types.js";
9
+ import {
10
+ getConfig,
11
+ loadTokenMap,
12
+ resolveProjectId,
13
+ resolveToken,
14
+ } from "./config.js";
15
+ import { AppError } from "./errors.js";
16
+ import type { DetailInput, SearchInput } from "./types.js";
17
+ import { YApiClient } from "./yapi.js";
18
+
19
+ const config = getConfig();
20
+ const tokenMap = loadTokenMap(config.tokenFilePath);
21
+ const yapi = new YApiClient({
22
+ baseUrl: config.baseUrl,
23
+ timeoutMs: config.timeoutMs,
24
+ retryCount: config.retryCount,
25
+ });
26
+
27
+ const server = new Server(
28
+ {
29
+ name: "yapi-mcp-server",
30
+ version: "0.1.0",
31
+ },
32
+ {
33
+ capabilities: {
34
+ tools: {},
35
+ },
36
+ },
37
+ );
38
+
39
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
40
+ return {
41
+ tools: [
42
+ {
43
+ name: "search_yapi_interfaces",
44
+ description:
45
+ "Search YApi interfaces by natural language keyword within a project. Token scope is project-level only.",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ keyword: { type: "string", minLength: 1 },
50
+ projectId: { type: "number" },
51
+ projectUrl: { type: "string" },
52
+ method: {
53
+ type: "string",
54
+ enum: ["GET", "POST", "PUT", "DELETE", "PATCH"],
55
+ },
56
+ pathHint: { type: "string" },
57
+ limit: { type: "number", minimum: 1, maximum: 50 },
58
+ },
59
+ required: ["keyword"],
60
+ additionalProperties: false,
61
+ },
62
+ },
63
+ {
64
+ name: "get_yapi_interface_detail",
65
+ description:
66
+ "Get YApi interface detail including request/response schema. Token scope is project-level only.",
67
+ inputSchema: {
68
+ type: "object",
69
+ properties: {
70
+ interfaceId: { type: "number" },
71
+ projectId: { type: "number" },
72
+ projectUrl: { type: "string" },
73
+ includeMock: { type: "boolean" },
74
+ },
75
+ required: ["interfaceId"],
76
+ additionalProperties: false,
77
+ },
78
+ },
79
+ ],
80
+ };
81
+ });
82
+
83
+ server.setRequestHandler(
84
+ CallToolRequestSchema,
85
+ async (request: CallToolRequest) => {
86
+ try {
87
+ const { name, arguments: args } = request.params;
88
+
89
+ if (name === "search_yapi_interfaces") {
90
+ const input = (args ?? {}) as unknown as SearchInput;
91
+ if (!input.keyword?.trim()) {
92
+ throw new AppError({
93
+ code: "INVALID_ARGUMENT",
94
+ message: "keyword is required",
95
+ suggestion: "Pass keyword like '工单详情'",
96
+ });
97
+ }
98
+
99
+ const projectId = resolveProjectId({
100
+ projectId: input.projectId,
101
+ projectUrl: input.projectUrl,
102
+ });
103
+
104
+ const token = resolveToken({
105
+ projectId,
106
+ tokenMap,
107
+ fallbackToken: config.fallbackToken,
108
+ });
109
+
110
+ const limit = clampLimit(input.limit);
111
+ const result = await yapi.searchInterfaces({
112
+ projectId,
113
+ token,
114
+ keyword: input.keyword,
115
+ method: input.method,
116
+ pathHint: input.pathHint,
117
+ limit,
118
+ });
119
+
120
+ return ok({
121
+ projectId,
122
+ count: result.length,
123
+ items: result,
124
+ });
125
+ }
126
+
127
+ if (name === "get_yapi_interface_detail") {
128
+ const input = (args ?? {}) as unknown as DetailInput;
129
+ if (!Number.isFinite(input.interfaceId)) {
130
+ throw new AppError({
131
+ code: "INVALID_ARGUMENT",
132
+ message: "interfaceId is required and must be a number",
133
+ });
134
+ }
135
+
136
+ const projectId = resolveProjectId({
137
+ projectId: input.projectId,
138
+ projectUrl: input.projectUrl,
139
+ });
140
+
141
+ const token = resolveToken({
142
+ projectId,
143
+ tokenMap,
144
+ fallbackToken: config.fallbackToken,
145
+ });
146
+
147
+ const detail = await yapi.getInterfaceDetail({
148
+ interfaceId: input.interfaceId,
149
+ token,
150
+ includeMock: input.includeMock,
151
+ });
152
+
153
+ return ok({
154
+ projectId,
155
+ interfaceId: input.interfaceId,
156
+ detail,
157
+ });
158
+ }
159
+
160
+ throw new AppError({
161
+ code: "INVALID_ARGUMENT",
162
+ message: `Unknown tool: ${name}`,
163
+ });
164
+ } catch (error) {
165
+ return fail(error);
166
+ }
167
+ },
168
+ );
169
+
170
+ async function main() {
171
+ const transport = new StdioServerTransport();
172
+ await server.connect(transport);
173
+ }
174
+
175
+ function ok(data: unknown) {
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text" as const,
180
+ text: JSON.stringify(
181
+ {
182
+ ok: true,
183
+ data,
184
+ },
185
+ null,
186
+ 2,
187
+ ),
188
+ },
189
+ ],
190
+ };
191
+ }
192
+
193
+ function fail(error: unknown) {
194
+ if (error instanceof AppError) {
195
+ return {
196
+ isError: true,
197
+ content: [
198
+ {
199
+ type: "text" as const,
200
+ text: JSON.stringify(
201
+ {
202
+ ok: false,
203
+ error: error.toShape(),
204
+ },
205
+ null,
206
+ 2,
207
+ ),
208
+ },
209
+ ],
210
+ };
211
+ }
212
+
213
+ return {
214
+ isError: true,
215
+ content: [
216
+ {
217
+ type: "text" as const,
218
+ text: JSON.stringify(
219
+ {
220
+ ok: false,
221
+ error: {
222
+ code: "UPSTREAM_ERROR",
223
+ message: error instanceof Error ? error.message : "Unknown error",
224
+ },
225
+ },
226
+ null,
227
+ 2,
228
+ ),
229
+ },
230
+ ],
231
+ };
232
+ }
233
+
234
+ function clampLimit(limit?: number): number {
235
+ if (!Number.isFinite(limit)) return 10;
236
+ return Math.min(50, Math.max(1, Number(limit)));
237
+ }
238
+
239
+ main().catch((error) => {
240
+ const payload =
241
+ error instanceof AppError
242
+ ? { ok: false, error: error.toShape() }
243
+ : {
244
+ ok: false,
245
+ error: {
246
+ code: "UPSTREAM_ERROR",
247
+ message:
248
+ error instanceof Error ? error.message : "Unknown startup error",
249
+ },
250
+ };
251
+
252
+ process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
253
+ process.exit(1);
254
+ });
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ export type ErrorCode =
2
+ | "INVALID_ARGUMENT"
3
+ | "PROJECT_ID_REQUIRED"
4
+ | "PROJECT_TOKEN_REQUIRED"
5
+ | "TOKEN_SCOPE_UNSUPPORTED"
6
+ | "PROJECT_NOT_ACCESSIBLE"
7
+ | "INTERFACE_NOT_FOUND"
8
+ | "UPSTREAM_TIMEOUT"
9
+ | "UPSTREAM_ERROR"
10
+ | "RATE_LIMITED";
11
+
12
+ export interface ApiErrorShape {
13
+ code: ErrorCode;
14
+ message: string;
15
+ suggestion?: string;
16
+ context?: Record<string, unknown>;
17
+ }
18
+
19
+ export interface SearchInput {
20
+ keyword: string;
21
+ projectId?: number;
22
+ projectUrl?: string;
23
+ method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
24
+ pathHint?: string;
25
+ limit?: number;
26
+ }
27
+
28
+ export interface DetailInput {
29
+ interfaceId: number;
30
+ projectId?: number;
31
+ projectUrl?: string;
32
+ includeMock?: boolean;
33
+ }
34
+
35
+ export interface YApiInterfaceSummary {
36
+ interfaceId: number;
37
+ name: string;
38
+ method?: string;
39
+ path?: string;
40
+ projectId: number;
41
+ score: number;
42
+ }