youjiekou-mcp-server 0.1.9
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 +180 -0
- package/bin/youjk-mcp-server.js +2 -0
- package/dist/cache.d.ts +11 -0
- package/dist/cache.js +29 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.js +74 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +71 -0
- package/dist/normalize.d.ts +6 -0
- package/dist/normalize.js +193 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +367 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# youjiekou-mcp-server
|
|
2
|
+
|
|
3
|
+
友接口 MCP Server:面向 AI 编程助手,提供项目接口文档的**读取**与**推写**(create / update / upsert)能力。
|
|
4
|
+
|
|
5
|
+
> 一期能力:在同一套 MCP 中完成接口文档查询与接口定义同步,配置通用、无需本机路径。
|
|
6
|
+
|
|
7
|
+
## 前置
|
|
8
|
+
|
|
9
|
+
- Node.js ≥ 18
|
|
10
|
+
- 友接口后端已部署 MCP 相关能力(PAT + 读写 API)
|
|
11
|
+
- 已生成 PAT(推接口需 scopes 含 `apis:write`)
|
|
12
|
+
|
|
13
|
+
## 生成 PAT(curl)
|
|
14
|
+
|
|
15
|
+
登录态 token 放在 Header `token`:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 只读
|
|
19
|
+
curl -X POST -H "token: <LOGIN_TOKEN>" -H "Content-Type: application/json" \
|
|
20
|
+
-d '{"name":"ide-read","scopes":["docs:read"]}' \
|
|
21
|
+
"https://<HOST>/api/mcp/token/create"
|
|
22
|
+
|
|
23
|
+
# 读写(推荐日常推接口)
|
|
24
|
+
curl -X POST -H "token: <LOGIN_TOKEN>" -H "Content-Type: application/json" \
|
|
25
|
+
-d '{"name":"ide-rw","scopes":["docs:read","apis:write"]}' \
|
|
26
|
+
"https://<HOST>/api/mcp/token/create"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
明文 token **只返回一次**,请立即写入本地 MCP 配置中的 `YOUJK_ACCESS_TOKEN`。
|
|
30
|
+
|
|
31
|
+
## MCP 客户端配置(无本地路径)
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"youjk-mcp": {
|
|
37
|
+
"command": "npx",
|
|
38
|
+
"args": [
|
|
39
|
+
"-y",
|
|
40
|
+
"youjiekou-mcp-server@latest",
|
|
41
|
+
"--project-id=<PROJECT_ID>"
|
|
42
|
+
],
|
|
43
|
+
"env": {
|
|
44
|
+
"YOUJK_ACCESS_TOKEN": "<刚才拿到的 yjk_... PAT>",
|
|
45
|
+
"YOUJK_BASE_URL": "https://<HOST>"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Windows 若 `npx` 起不来,改用:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"youjk-mcp": {
|
|
58
|
+
"command": "cmd",
|
|
59
|
+
"args": [
|
|
60
|
+
"/c",
|
|
61
|
+
"npx",
|
|
62
|
+
"-y",
|
|
63
|
+
"youjiekou-mcp-server@latest",
|
|
64
|
+
"--project-id=<PROJECT_ID>"
|
|
65
|
+
],
|
|
66
|
+
"env": {
|
|
67
|
+
"YOUJK_ACCESS_TOKEN": "<刚才拿到的 yjk_... PAT>",
|
|
68
|
+
"YOUJK_BASE_URL": "https://<HOST>"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Tools
|
|
76
|
+
|
|
77
|
+
| Tool | 类型 | 说明 |
|
|
78
|
+
|------|------|------|
|
|
79
|
+
| `list_apis` | 只读 | 列接口摘要 |
|
|
80
|
+
| `get_api_detail` | 只读 | 单接口详情(含 params/body/responses) |
|
|
81
|
+
| `search_apis` | 只读 | 按关键词搜索 |
|
|
82
|
+
| `create_api` | **写** | 新建接口(请带 params/body) |
|
|
83
|
+
| `update_api` | **写** | 按 id merge 更新 |
|
|
84
|
+
| `upsert_api` | **写** | 按 method+url 有则改无则建(推荐) |
|
|
85
|
+
| `refresh_cache` | 本地 | 清空进程内缓存 |
|
|
86
|
+
|
|
87
|
+
### 推接口:params / body 完整落库
|
|
88
|
+
|
|
89
|
+
`create_api` / `update_api` / `upsert_api` 支持完整 `params`、`body`、`responses`。推送后用 `get_api_detail` 回读校验。
|
|
90
|
+
|
|
91
|
+
友接口格式(推荐):
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"name": "创建用户",
|
|
96
|
+
"method": "POST",
|
|
97
|
+
"url": "/users",
|
|
98
|
+
"description": "创建用户",
|
|
99
|
+
"sourcePath": "src/user/controller.ts:42",
|
|
100
|
+
"params": [
|
|
101
|
+
{
|
|
102
|
+
"name": "X-Request-Id",
|
|
103
|
+
"type": "string",
|
|
104
|
+
"location": "HEADER",
|
|
105
|
+
"required": true,
|
|
106
|
+
"example": "req-1"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "id",
|
|
110
|
+
"type": "integer",
|
|
111
|
+
"location": "PATH",
|
|
112
|
+
"required": true,
|
|
113
|
+
"example": 1
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
"body": {
|
|
117
|
+
"type": "RAW_JSON",
|
|
118
|
+
"example": { "name": "tom", "age": 18 },
|
|
119
|
+
"jsonSchema": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"name": { "type": "string" },
|
|
123
|
+
"age": { "type": "integer" }
|
|
124
|
+
},
|
|
125
|
+
"required": ["name"]
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"responses": [
|
|
129
|
+
{
|
|
130
|
+
"name": "成功",
|
|
131
|
+
"statusCode": 200,
|
|
132
|
+
"schema": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": { "id": { "type": "integer" } }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
说明:
|
|
142
|
+
|
|
143
|
+
- `params[].location`:`QUERY` | `PATH` | `HEADER` | `COOKIE`(兼容小写或 OpenAPI `in`)
|
|
144
|
+
- `body.type`:`RAW_JSON` | `FORM_DATA` | `X_WWW_FORM_URLENCODED` | `RAW_XML` | `RAW_TEXT` | `BINARY`(兼容 `json` / OpenAPI `content`)
|
|
145
|
+
- `body.example` 可为对象,服务端会按 JSON 字符串落库;**仅有 jsonSchema 无 example 时会自动生成示例**,写入调试/请求体区域
|
|
146
|
+
- `sourcePath`:**必填**(create/upsert)。传代码文件路径;服务端落库为 `2026-09-14 11:18 张三 通过 MCP 导入 src/xxx.ts:42`,文档「接口说明 → 来源」展示
|
|
147
|
+
- `moduleName`:仅对话明确「推到 xxx 模块」时传入——有则归入、无则新建;**省略则归入「默认模块」**,勿自行编造模块名
|
|
148
|
+
- `folderPath`:仅对话明确指定目录时传入(如 `["订单","支付"]`);**省略则按 url 自动建外层文件夹**(对标 Apifox,接口不摊平在一层)
|
|
149
|
+
- merge 更新时:未传的 `params`/`body` 保留原值;要改就传完整对象/列表
|
|
150
|
+
|
|
151
|
+
## 本地开发
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
cd youjk-mcp-server
|
|
155
|
+
npm install
|
|
156
|
+
npm run build
|
|
157
|
+
set YOUJK_ACCESS_TOKEN=...
|
|
158
|
+
set YOUJK_BASE_URL=http://127.0.0.1:端口
|
|
159
|
+
node dist/index.js --project-id=1
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
临时用本地路径接入 MCP 客户端(仅开发;正式环境须 `npx`):
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
"youjk-mcp-local": {
|
|
166
|
+
"command": "node",
|
|
167
|
+
"args": ["D:/workspace/youjk-mcp-server/dist/index.js", "--project-id=1"],
|
|
168
|
+
"env": {
|
|
169
|
+
"YOUJK_ACCESS_TOKEN": "...",
|
|
170
|
+
"YOUJK_BASE_URL": "http://127.0.0.1:端口"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## 常见问题
|
|
176
|
+
|
|
177
|
+
- **401 / 未授权**:PAT 无效、已吊销或过期
|
|
178
|
+
- **403 缺少权限: apis:write**:PAT 未包含写 scope
|
|
179
|
+
- **无写入权限**:项目成员仅为只读
|
|
180
|
+
- **空列表**:projectId 错误或项目尚无接口
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ApiSummary } from "./types.js";
|
|
2
|
+
export declare class MemoryCache {
|
|
3
|
+
private list;
|
|
4
|
+
private details;
|
|
5
|
+
getList(): ApiSummary[] | null;
|
|
6
|
+
setList(list: ApiSummary[]): void;
|
|
7
|
+
getDetail(id: number): unknown | undefined;
|
|
8
|
+
setDetail(id: number, detail: unknown): void;
|
|
9
|
+
invalidateAfterWrite(apiId?: number): void;
|
|
10
|
+
clear(): void;
|
|
11
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class MemoryCache {
|
|
2
|
+
list = null;
|
|
3
|
+
details = new Map();
|
|
4
|
+
getList() {
|
|
5
|
+
return this.list;
|
|
6
|
+
}
|
|
7
|
+
setList(list) {
|
|
8
|
+
this.list = list;
|
|
9
|
+
}
|
|
10
|
+
getDetail(id) {
|
|
11
|
+
return this.details.get(id);
|
|
12
|
+
}
|
|
13
|
+
setDetail(id, detail) {
|
|
14
|
+
this.details.set(id, detail);
|
|
15
|
+
}
|
|
16
|
+
invalidateAfterWrite(apiId) {
|
|
17
|
+
this.list = null;
|
|
18
|
+
if (apiId != null && apiId > 0) {
|
|
19
|
+
this.details.delete(apiId);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
this.details.clear();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
clear() {
|
|
26
|
+
this.list = null;
|
|
27
|
+
this.details.clear();
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ApiSummary, ApiWriteBody, ApiWriteResult, YoujkConfig } from "./types.js";
|
|
2
|
+
export declare class YoujkHttpError extends Error {
|
|
3
|
+
readonly status?: number | undefined;
|
|
4
|
+
readonly code?: number | undefined;
|
|
5
|
+
constructor(message: string, status?: number | undefined, code?: number | undefined);
|
|
6
|
+
}
|
|
7
|
+
export declare class YoujkClient {
|
|
8
|
+
private readonly cfg;
|
|
9
|
+
constructor(cfg: YoujkConfig);
|
|
10
|
+
private url;
|
|
11
|
+
private request;
|
|
12
|
+
listApis(): Promise<ApiSummary[]>;
|
|
13
|
+
getApiDetail(apiId: number | string): Promise<unknown>;
|
|
14
|
+
createApi(body: ApiWriteBody): Promise<ApiWriteResult>;
|
|
15
|
+
updateApi(apiId: number | string, body: Record<string, unknown>): Promise<ApiWriteResult>;
|
|
16
|
+
upsertApi(body: ApiWriteBody): Promise<ApiWriteResult>;
|
|
17
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export class YoujkHttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, status, code) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "YoujkHttpError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class YoujkClient {
|
|
12
|
+
cfg;
|
|
13
|
+
constructor(cfg) {
|
|
14
|
+
this.cfg = cfg;
|
|
15
|
+
}
|
|
16
|
+
url(path) {
|
|
17
|
+
const base = this.cfg.baseUrl.replace(/\/+$/, "");
|
|
18
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
19
|
+
return `${base}${p}`;
|
|
20
|
+
}
|
|
21
|
+
async request(method, path, body) {
|
|
22
|
+
const headers = {
|
|
23
|
+
Accept: "application/json",
|
|
24
|
+
Authorization: `Bearer ${this.cfg.accessToken}`,
|
|
25
|
+
};
|
|
26
|
+
const init = { method, headers };
|
|
27
|
+
if (body !== undefined) {
|
|
28
|
+
headers["Content-Type"] = "application/json";
|
|
29
|
+
init.body = JSON.stringify(body);
|
|
30
|
+
}
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
33
|
+
let res;
|
|
34
|
+
try {
|
|
35
|
+
res = await fetch(this.url(path), { ...init, signal: controller.signal });
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
39
|
+
throw new YoujkHttpError(`请求友接口失败: ${msg}`);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
let data;
|
|
45
|
+
try {
|
|
46
|
+
data = (await res.json());
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new YoujkHttpError(`友接口返回非 JSON (HTTP ${res.status})`, res.status);
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new YoujkHttpError(data.message || `HTTP ${res.status}`, res.status, data.code);
|
|
53
|
+
}
|
|
54
|
+
if (data.code !== 0) {
|
|
55
|
+
throw new YoujkHttpError(data.message || `业务错误 code=${data.code}`, res.status, data.code);
|
|
56
|
+
}
|
|
57
|
+
return data.result;
|
|
58
|
+
}
|
|
59
|
+
listApis() {
|
|
60
|
+
return this.request("GET", `/api/mcp/projects/${this.cfg.projectId}/apis`);
|
|
61
|
+
}
|
|
62
|
+
getApiDetail(apiId) {
|
|
63
|
+
return this.request("GET", `/api/mcp/apis/${apiId}`);
|
|
64
|
+
}
|
|
65
|
+
createApi(body) {
|
|
66
|
+
return this.request("POST", `/api/mcp/projects/${this.cfg.projectId}/apis`, body);
|
|
67
|
+
}
|
|
68
|
+
updateApi(apiId, body) {
|
|
69
|
+
return this.request("PUT", `/api/mcp/apis/${apiId}`, body);
|
|
70
|
+
}
|
|
71
|
+
upsertApi(body) {
|
|
72
|
+
return this.request("POST", `/api/mcp/projects/${this.cfg.projectId}/apis/upsert`, body);
|
|
73
|
+
}
|
|
74
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { createYoujkMcpServer } from "./server.js";
|
|
4
|
+
function printUsage() {
|
|
5
|
+
console.error(`youjiekou-mcp-server — 友接口 MCP(读文档 + 推写接口)
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
youjiekou-mcp-server --project-id=<PROJECT_ID>
|
|
9
|
+
|
|
10
|
+
环境变量(必填):
|
|
11
|
+
YOUJK_ACCESS_TOKEN PAT(推接口需含 apis:write)
|
|
12
|
+
YOUJK_BASE_URL 友接口后端根地址,如 https://api.example.com
|
|
13
|
+
|
|
14
|
+
可选参数:
|
|
15
|
+
--project-id= / --project= 默认项目 ID
|
|
16
|
+
--help 显示帮助
|
|
17
|
+
`);
|
|
18
|
+
}
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
let projectId;
|
|
21
|
+
let help = false;
|
|
22
|
+
for (const raw of argv) {
|
|
23
|
+
if (raw === "--help" || raw === "-h") {
|
|
24
|
+
help = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const m = /^--project(?:-id)?=(.+)$/i.exec(raw);
|
|
28
|
+
if (m) {
|
|
29
|
+
projectId = m[1].trim();
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (raw === "--project" || raw === "--project-id") {
|
|
33
|
+
console.error("请使用 --project-id=<id> 形式(等号赋值)");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { projectId, help };
|
|
38
|
+
}
|
|
39
|
+
function loadConfig() {
|
|
40
|
+
const { projectId, help } = parseArgs(process.argv.slice(2));
|
|
41
|
+
if (help) {
|
|
42
|
+
printUsage();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
const accessToken = (process.env.YOUJK_ACCESS_TOKEN || "").trim();
|
|
46
|
+
const baseUrl = (process.env.YOUJK_BASE_URL || "").trim();
|
|
47
|
+
const pid = (projectId || "").trim();
|
|
48
|
+
const missing = [];
|
|
49
|
+
if (!accessToken)
|
|
50
|
+
missing.push("环境变量 YOUJK_ACCESS_TOKEN");
|
|
51
|
+
if (!baseUrl)
|
|
52
|
+
missing.push("环境变量 YOUJK_BASE_URL");
|
|
53
|
+
if (!pid)
|
|
54
|
+
missing.push("参数 --project-id=<PROJECT_ID>");
|
|
55
|
+
if (missing.length) {
|
|
56
|
+
console.error(`启动失败,缺少: ${missing.join(";")}`);
|
|
57
|
+
printUsage();
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
return { accessToken, baseUrl, projectId: pid };
|
|
61
|
+
}
|
|
62
|
+
async function main() {
|
|
63
|
+
const cfg = loadConfig();
|
|
64
|
+
const server = createYoujkMcpServer(cfg);
|
|
65
|
+
const transport = new StdioServerTransport();
|
|
66
|
+
await server.connect(transport);
|
|
67
|
+
}
|
|
68
|
+
main().catch((err) => {
|
|
69
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** 将 MCP 入参归一化为友接口 ParamDTO / BodyDTO,兼容 OpenAPI 形态。 */
|
|
2
|
+
export declare function normalizeParam(input: unknown): Record<string, unknown> | null;
|
|
3
|
+
export declare function normalizeParams(input: unknown): Record<string, unknown>[] | undefined;
|
|
4
|
+
export declare function normalizeBody(input: unknown): Record<string, unknown> | undefined;
|
|
5
|
+
export declare function normalizeResponse(input: unknown): Record<string, unknown> | null;
|
|
6
|
+
export declare function normalizeResponses(input: unknown): Record<string, unknown>[] | undefined;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** 将 MCP 入参归一化为友接口 ParamDTO / BodyDTO,兼容 OpenAPI 形态。 */
|
|
2
|
+
const LOCATION_MAP = {
|
|
3
|
+
query: "QUERY",
|
|
4
|
+
path: "PATH",
|
|
5
|
+
header: "HEADER",
|
|
6
|
+
cookie: "COOKIE",
|
|
7
|
+
body: "QUERY",
|
|
8
|
+
QUERY: "QUERY",
|
|
9
|
+
PATH: "PATH",
|
|
10
|
+
HEADER: "HEADER",
|
|
11
|
+
COOKIE: "COOKIE",
|
|
12
|
+
};
|
|
13
|
+
const BODY_TYPE_MAP = {
|
|
14
|
+
FORM_DATA: "FORM_DATA",
|
|
15
|
+
"FORM-DATA": "FORM_DATA",
|
|
16
|
+
multipart: "FORM_DATA",
|
|
17
|
+
"multipart/form-data": "FORM_DATA",
|
|
18
|
+
X_WWW_FORM_URLENCODED: "X_WWW_FORM_URLENCODED",
|
|
19
|
+
"x-www-form-urlencoded": "X_WWW_FORM_URLENCODED",
|
|
20
|
+
"application/x-www-form-urlencoded": "X_WWW_FORM_URLENCODED",
|
|
21
|
+
RAW_JSON: "RAW_JSON",
|
|
22
|
+
json: "RAW_JSON",
|
|
23
|
+
"application/json": "RAW_JSON",
|
|
24
|
+
RAW_XML: "RAW_XML",
|
|
25
|
+
xml: "RAW_XML",
|
|
26
|
+
"application/xml": "RAW_XML",
|
|
27
|
+
RAW_TEXT: "RAW_TEXT",
|
|
28
|
+
text: "RAW_TEXT",
|
|
29
|
+
"text/plain": "RAW_TEXT",
|
|
30
|
+
BINARY: "BINARY",
|
|
31
|
+
"application/octet-stream": "BINARY",
|
|
32
|
+
};
|
|
33
|
+
function asRecord(v) {
|
|
34
|
+
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
35
|
+
? v
|
|
36
|
+
: null;
|
|
37
|
+
}
|
|
38
|
+
function exampleToValue(example) {
|
|
39
|
+
if (example === undefined || example === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
return example;
|
|
42
|
+
}
|
|
43
|
+
export function normalizeParam(input) {
|
|
44
|
+
const p = asRecord(input);
|
|
45
|
+
if (!p)
|
|
46
|
+
return null;
|
|
47
|
+
const name = String(p.name ?? "").trim();
|
|
48
|
+
if (!name)
|
|
49
|
+
return null;
|
|
50
|
+
const inLoc = String(p.in ?? "").trim();
|
|
51
|
+
const locationRaw = String(p.location ?? inLoc ?? "QUERY").trim();
|
|
52
|
+
const location = LOCATION_MAP[locationRaw] ||
|
|
53
|
+
LOCATION_MAP[locationRaw.toLowerCase()] ||
|
|
54
|
+
"QUERY";
|
|
55
|
+
const schema = asRecord(p.schema);
|
|
56
|
+
let type = String(p.type ?? schema?.type ?? "string").trim() || "string";
|
|
57
|
+
const out = {
|
|
58
|
+
name,
|
|
59
|
+
type,
|
|
60
|
+
location,
|
|
61
|
+
required: Boolean(p.required),
|
|
62
|
+
description: p.description != null ? String(p.description) : "",
|
|
63
|
+
};
|
|
64
|
+
const example = exampleToValue(p.example ?? schema?.example);
|
|
65
|
+
if (example !== undefined)
|
|
66
|
+
out.example = example;
|
|
67
|
+
if (p.mockScript != null)
|
|
68
|
+
out.mockScript = String(p.mockScript);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
export function normalizeParams(input) {
|
|
72
|
+
if (input === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (!Array.isArray(input))
|
|
75
|
+
return [];
|
|
76
|
+
return input.map(normalizeParam).filter((x) => x != null);
|
|
77
|
+
}
|
|
78
|
+
function convertOpenAPIContent(content) {
|
|
79
|
+
const formMedia = asRecord(content["multipart/form-data"]);
|
|
80
|
+
if (formMedia) {
|
|
81
|
+
const schema = asRecord(formMedia.schema);
|
|
82
|
+
const formData = [];
|
|
83
|
+
const props = asRecord(schema?.properties) || {};
|
|
84
|
+
const required = Array.isArray(schema?.required)
|
|
85
|
+
? schema.required
|
|
86
|
+
: [];
|
|
87
|
+
for (const [name, propRaw] of Object.entries(props)) {
|
|
88
|
+
const prop = asRecord(propRaw) || {};
|
|
89
|
+
formData.push({
|
|
90
|
+
name,
|
|
91
|
+
type: String(prop.type ?? "string"),
|
|
92
|
+
required: required.includes(name),
|
|
93
|
+
example: prop.example ?? "",
|
|
94
|
+
description: prop.description != null ? String(prop.description) : "",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return { type: "FORM_DATA", formData };
|
|
98
|
+
}
|
|
99
|
+
const jsonMedia = asRecord(content["application/json"]) ||
|
|
100
|
+
Object.values(content)
|
|
101
|
+
.map(asRecord)
|
|
102
|
+
.find((m) => m && (m.schema != null || m.example != null));
|
|
103
|
+
if (jsonMedia) {
|
|
104
|
+
return {
|
|
105
|
+
type: "RAW_JSON",
|
|
106
|
+
example: jsonMedia.example ?? null,
|
|
107
|
+
jsonSchema: jsonMedia.schema ?? {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {},
|
|
110
|
+
required: [],
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
export function normalizeBody(input) {
|
|
117
|
+
if (input === undefined)
|
|
118
|
+
return undefined;
|
|
119
|
+
if (input === null)
|
|
120
|
+
return undefined;
|
|
121
|
+
const b = asRecord(input);
|
|
122
|
+
if (!b)
|
|
123
|
+
return undefined;
|
|
124
|
+
// OpenAPI requestBody
|
|
125
|
+
const content = asRecord(b.content);
|
|
126
|
+
if (content && !b.type) {
|
|
127
|
+
const converted = convertOpenAPIContent(content);
|
|
128
|
+
if (converted)
|
|
129
|
+
return converted;
|
|
130
|
+
}
|
|
131
|
+
const typeRaw = String(b.type ?? "").trim();
|
|
132
|
+
let type = BODY_TYPE_MAP[typeRaw] ||
|
|
133
|
+
BODY_TYPE_MAP[typeRaw.toLowerCase()] ||
|
|
134
|
+
(typeRaw ? typeRaw.toUpperCase() : "");
|
|
135
|
+
if (!type) {
|
|
136
|
+
if (Array.isArray(b.formData) && b.formData.length > 0)
|
|
137
|
+
type = "FORM_DATA";
|
|
138
|
+
else if (b.jsonSchema != null || b.example != null)
|
|
139
|
+
type = "RAW_JSON";
|
|
140
|
+
else if (b.xmlSchema)
|
|
141
|
+
type = "RAW_XML";
|
|
142
|
+
else if (b.textContent)
|
|
143
|
+
type = "RAW_TEXT";
|
|
144
|
+
else if (b.binaryDescription)
|
|
145
|
+
type = "BINARY";
|
|
146
|
+
else
|
|
147
|
+
type = "RAW_JSON";
|
|
148
|
+
}
|
|
149
|
+
const out = { type };
|
|
150
|
+
if (b.example !== undefined)
|
|
151
|
+
out.example = b.example;
|
|
152
|
+
if (b.jsonSchema !== undefined)
|
|
153
|
+
out.jsonSchema = b.jsonSchema;
|
|
154
|
+
if (b.formData !== undefined)
|
|
155
|
+
out.formData = b.formData;
|
|
156
|
+
if (b.xmlSchema !== undefined)
|
|
157
|
+
out.xmlSchema = b.xmlSchema;
|
|
158
|
+
if (b.textContent !== undefined)
|
|
159
|
+
out.textContent = b.textContent;
|
|
160
|
+
if (b.binaryDescription !== undefined)
|
|
161
|
+
out.binaryDescription = b.binaryDescription;
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
export function normalizeResponse(input) {
|
|
165
|
+
const r = asRecord(input);
|
|
166
|
+
if (!r)
|
|
167
|
+
return null;
|
|
168
|
+
const out = {
|
|
169
|
+
name: r.name != null ? String(r.name) : "成功",
|
|
170
|
+
statusCode: typeof r.statusCode === "number"
|
|
171
|
+
? r.statusCode
|
|
172
|
+
: Number(r.statusCode) || 200,
|
|
173
|
+
description: r.description != null ? String(r.description) : "",
|
|
174
|
+
};
|
|
175
|
+
if (r.schema !== undefined)
|
|
176
|
+
out.schema = r.schema;
|
|
177
|
+
if (r.example !== undefined)
|
|
178
|
+
out.example = r.example;
|
|
179
|
+
if (r.mockScript != null)
|
|
180
|
+
out.mockScript = String(r.mockScript);
|
|
181
|
+
if (r.headers !== undefined)
|
|
182
|
+
out.headers = r.headers;
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
export function normalizeResponses(input) {
|
|
186
|
+
if (input === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
if (!Array.isArray(input))
|
|
189
|
+
return [];
|
|
190
|
+
return input
|
|
191
|
+
.map(normalizeResponse)
|
|
192
|
+
.filter((x) => x != null);
|
|
193
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { MemoryCache } from "./cache.js";
|
|
4
|
+
import { YoujkClient, YoujkHttpError } from "./client.js";
|
|
5
|
+
import { normalizeBody, normalizeParams, normalizeResponses, } from "./normalize.js";
|
|
6
|
+
function textResult(payload, isError = false) {
|
|
7
|
+
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
|
|
8
|
+
return { content: [{ type: "text", text }], isError };
|
|
9
|
+
}
|
|
10
|
+
function errResult(e) {
|
|
11
|
+
const msg = e instanceof YoujkHttpError
|
|
12
|
+
? e.message
|
|
13
|
+
: e instanceof Error
|
|
14
|
+
? e.message
|
|
15
|
+
: String(e);
|
|
16
|
+
return textResult(msg, true);
|
|
17
|
+
}
|
|
18
|
+
function matchesKeyword(item, keyword) {
|
|
19
|
+
const k = keyword.trim().toLowerCase();
|
|
20
|
+
if (!k)
|
|
21
|
+
return true;
|
|
22
|
+
return (item.name.toLowerCase().includes(k) ||
|
|
23
|
+
item.url.toLowerCase().includes(k) ||
|
|
24
|
+
item.method.toLowerCase().includes(k) ||
|
|
25
|
+
String(item.id).includes(k));
|
|
26
|
+
}
|
|
27
|
+
const paramSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
name: z.string().min(1).describe("参数名"),
|
|
30
|
+
type: z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("参数类型,如 string/integer/number/boolean"),
|
|
34
|
+
location: z
|
|
35
|
+
.enum(["QUERY", "PATH", "HEADER", "COOKIE", "query", "path", "header", "cookie"])
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("参数位置;也可用 OpenAPI 的 in 字段"),
|
|
38
|
+
in: z
|
|
39
|
+
.enum(["query", "path", "header", "cookie"])
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("OpenAPI parameters[].in(与 location 二选一)"),
|
|
42
|
+
required: z.boolean().optional(),
|
|
43
|
+
example: z.unknown().optional(),
|
|
44
|
+
description: z.string().optional(),
|
|
45
|
+
mockScript: z.string().optional(),
|
|
46
|
+
schema: z.record(z.string(), z.unknown()).optional(),
|
|
47
|
+
})
|
|
48
|
+
.passthrough();
|
|
49
|
+
const bodySchema = z
|
|
50
|
+
.object({
|
|
51
|
+
type: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("请求体类型:RAW_JSON / FORM_DATA / X_WWW_FORM_URLENCODED / RAW_XML / RAW_TEXT / BINARY;也可用 json"),
|
|
55
|
+
example: z.unknown().optional().describe("示例;对象会按 JSON 落库"),
|
|
56
|
+
jsonSchema: z.record(z.string(), z.unknown()).optional(),
|
|
57
|
+
formData: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
58
|
+
xmlSchema: z.string().optional(),
|
|
59
|
+
textContent: z.string().optional(),
|
|
60
|
+
binaryDescription: z.string().optional(),
|
|
61
|
+
content: z
|
|
62
|
+
.record(z.string(), z.unknown())
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("OpenAPI requestBody.content(可选,服务端/客户端会归一化)"),
|
|
65
|
+
})
|
|
66
|
+
.passthrough();
|
|
67
|
+
const responseSchema = z
|
|
68
|
+
.object({
|
|
69
|
+
name: z.string().optional(),
|
|
70
|
+
statusCode: z.union([z.number(), z.string()]).optional(),
|
|
71
|
+
description: z.string().optional(),
|
|
72
|
+
schema: z.record(z.string(), z.unknown()).optional(),
|
|
73
|
+
example: z.unknown().optional(),
|
|
74
|
+
mockScript: z.string().optional(),
|
|
75
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
76
|
+
})
|
|
77
|
+
.passthrough();
|
|
78
|
+
const UPSERT_EXAMPLE = `示例(推荐完整推送,推完可用 get_api_detail 回读):
|
|
79
|
+
{
|
|
80
|
+
"name": "创建用户",
|
|
81
|
+
"method": "POST",
|
|
82
|
+
"url": "/users",
|
|
83
|
+
"description": "创建用户",
|
|
84
|
+
"sourcePath": "src/user/controller.ts:42",
|
|
85
|
+
"params": [
|
|
86
|
+
{"name": "X-Request-Id", "type": "string", "location": "HEADER", "required": true, "example": "req-1"},
|
|
87
|
+
{"name": "id", "type": "integer", "location": "PATH", "required": true, "example": 1}
|
|
88
|
+
],
|
|
89
|
+
"body": {
|
|
90
|
+
"type": "RAW_JSON",
|
|
91
|
+
"example": {"name": "tom", "age": 18},
|
|
92
|
+
"jsonSchema": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"name": {"type": "string"},
|
|
96
|
+
"age": {"type": "integer"}
|
|
97
|
+
},
|
|
98
|
+
"required": ["name"]
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"responses": [
|
|
102
|
+
{"name": "成功", "statusCode": 200, "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}}
|
|
103
|
+
]
|
|
104
|
+
}`;
|
|
105
|
+
const writeFields = {
|
|
106
|
+
name: z.string().min(1).describe("接口名称"),
|
|
107
|
+
method: z.string().min(1).describe("HTTP 方法,如 GET/POST"),
|
|
108
|
+
url: z.string().min(1).describe("接口路径,如 /user/login"),
|
|
109
|
+
description: z.string().optional().describe("接口业务说明(展示在文档「接口说明」区)"),
|
|
110
|
+
sourcePath: z
|
|
111
|
+
.string()
|
|
112
|
+
.min(1)
|
|
113
|
+
.describe("【必填】代码侧来源路径,如 src/user/controller.ts:42。服务端落成「时间 操作人 通过 MCP 导入 该路径」,展示在文档「接口说明 → 来源」"),
|
|
114
|
+
moduleName: z
|
|
115
|
+
.string()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("目标模块名。仅当用户明确说「推到 xxx 模块」时传入:有则归入、无则新建。省略则归入「默认模块」(项目没有则自动创建),不要自行编造模块名"),
|
|
118
|
+
folderPath: z
|
|
119
|
+
.union([z.array(z.string()), z.string()])
|
|
120
|
+
.optional()
|
|
121
|
+
.describe('目标目录。仅当用户明确指定文件夹时传入,如 ["订单","支付"] 或 "订单/支付"。省略则服务端按 url 自动建外层文件夹(对标 Apifox,接口不摊平);不要用代码目录名冒充业务文件夹'),
|
|
122
|
+
menuId: z.number().int().positive().optional().describe("目录 ID(已有目录时可用;与 folderPath 二选一)"),
|
|
123
|
+
moduleId: z
|
|
124
|
+
.number()
|
|
125
|
+
.int()
|
|
126
|
+
.positive()
|
|
127
|
+
.optional()
|
|
128
|
+
.describe("模块 ID;缺省用 moduleName,再否则归入「默认模块」(项目无模块时自动创建)"),
|
|
129
|
+
params: z
|
|
130
|
+
.array(paramSchema)
|
|
131
|
+
.optional()
|
|
132
|
+
.describe("请求参数列表(ParamDTO)。location=QUERY|PATH|HEADER|COOKIE;兼容 OpenAPI in。务必随接口一并推送以便落库回读。"),
|
|
133
|
+
body: bodySchema
|
|
134
|
+
.optional()
|
|
135
|
+
.describe("请求体(BodyDTO)。JSON 接口用 type=RAW_JSON + example + jsonSchema;无 example 时服务端会按 jsonSchema 生成示例写入调试区。"),
|
|
136
|
+
responses: z
|
|
137
|
+
.array(responseSchema)
|
|
138
|
+
.optional()
|
|
139
|
+
.describe("响应定义列表(含 statusCode / schema)"),
|
|
140
|
+
status: z
|
|
141
|
+
.string()
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("接口状态:developing / testing / published(已完成)/ deprecated;省略时服务端默认 published"),
|
|
144
|
+
tags: z.array(z.string()).optional().describe("标签"),
|
|
145
|
+
};
|
|
146
|
+
function toWriteBody(args) {
|
|
147
|
+
const body = {
|
|
148
|
+
name: args.name,
|
|
149
|
+
method: args.method,
|
|
150
|
+
url: args.url,
|
|
151
|
+
};
|
|
152
|
+
if (args.description !== undefined)
|
|
153
|
+
body.description = args.description;
|
|
154
|
+
body.sourcePath = args.sourcePath;
|
|
155
|
+
if (args.moduleName !== undefined)
|
|
156
|
+
body.moduleName = args.moduleName;
|
|
157
|
+
if (args.folderPath !== undefined)
|
|
158
|
+
body.folderPath = args.folderPath;
|
|
159
|
+
if (args.menuId !== undefined)
|
|
160
|
+
body.menuId = args.menuId;
|
|
161
|
+
if (args.moduleId !== undefined)
|
|
162
|
+
body.moduleId = args.moduleId;
|
|
163
|
+
if (args.params !== undefined)
|
|
164
|
+
body.params = normalizeParams(args.params);
|
|
165
|
+
if (args.body !== undefined)
|
|
166
|
+
body.body = normalizeBody(args.body);
|
|
167
|
+
if (args.responses !== undefined) {
|
|
168
|
+
body.responses = normalizeResponses(args.responses);
|
|
169
|
+
}
|
|
170
|
+
if (args.status !== undefined)
|
|
171
|
+
body.status = args.status;
|
|
172
|
+
if (args.tags !== undefined)
|
|
173
|
+
body.tags = args.tags;
|
|
174
|
+
return body;
|
|
175
|
+
}
|
|
176
|
+
function toUpdatePatch(rest) {
|
|
177
|
+
const patch = {};
|
|
178
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
179
|
+
if (v === undefined)
|
|
180
|
+
continue;
|
|
181
|
+
if (k === "params") {
|
|
182
|
+
patch.params = normalizeParams(v);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (k === "body") {
|
|
186
|
+
const nb = normalizeBody(v);
|
|
187
|
+
if (nb !== undefined)
|
|
188
|
+
patch.body = nb;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (k === "responses") {
|
|
192
|
+
patch.responses = normalizeResponses(v);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
patch[k] = v;
|
|
196
|
+
}
|
|
197
|
+
return patch;
|
|
198
|
+
}
|
|
199
|
+
export function createYoujkMcpServer(cfg) {
|
|
200
|
+
const client = new YoujkClient(cfg);
|
|
201
|
+
const cache = new MemoryCache();
|
|
202
|
+
const server = new McpServer({
|
|
203
|
+
name: "youjk-mcp",
|
|
204
|
+
version: "0.1.9",
|
|
205
|
+
});
|
|
206
|
+
server.registerTool("list_apis", {
|
|
207
|
+
description: "列出当前友接口项目下的接口摘要(只读)。可选 keyword 按名称/路径/方法过滤。",
|
|
208
|
+
inputSchema: z.object({
|
|
209
|
+
keyword: z
|
|
210
|
+
.string()
|
|
211
|
+
.optional()
|
|
212
|
+
.describe("可选关键词,按名称/路径/方法/id 本地过滤"),
|
|
213
|
+
}),
|
|
214
|
+
}, async ({ keyword }) => {
|
|
215
|
+
try {
|
|
216
|
+
let list = cache.getList();
|
|
217
|
+
if (!list) {
|
|
218
|
+
list = await client.listApis();
|
|
219
|
+
cache.setList(list ?? []);
|
|
220
|
+
list = cache.getList();
|
|
221
|
+
}
|
|
222
|
+
const filtered = keyword
|
|
223
|
+
? list.filter((i) => matchesKeyword(i, keyword))
|
|
224
|
+
: list;
|
|
225
|
+
return textResult(filtered);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
return errResult(e);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
server.registerTool("get_api_detail", {
|
|
232
|
+
description: "获取友接口中单个接口的完整定义(只读):params、body、responses 等,用于推送后回读校验。",
|
|
233
|
+
inputSchema: z.object({
|
|
234
|
+
apiId: z.union([z.string(), z.number()]).describe("接口 ID"),
|
|
235
|
+
}),
|
|
236
|
+
}, async ({ apiId }) => {
|
|
237
|
+
try {
|
|
238
|
+
const id = typeof apiId === "number" ? apiId : Number(apiId);
|
|
239
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
240
|
+
return textResult("apiId 无效", true);
|
|
241
|
+
}
|
|
242
|
+
const cached = cache.getDetail(id);
|
|
243
|
+
if (cached !== undefined) {
|
|
244
|
+
return textResult(cached);
|
|
245
|
+
}
|
|
246
|
+
const detail = await client.getApiDetail(id);
|
|
247
|
+
cache.setDetail(id, detail);
|
|
248
|
+
return textResult(detail);
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
return errResult(e);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
server.registerTool("search_apis", {
|
|
255
|
+
description: "在当前友接口项目中搜索接口(只读)。写接口前建议先搜索避免重复。按名称/路径/方法匹配。",
|
|
256
|
+
inputSchema: z.object({
|
|
257
|
+
keyword: z.string().min(1).describe("搜索关键词"),
|
|
258
|
+
}),
|
|
259
|
+
}, async ({ keyword }) => {
|
|
260
|
+
try {
|
|
261
|
+
let list = cache.getList();
|
|
262
|
+
if (!list) {
|
|
263
|
+
list = await client.listApis();
|
|
264
|
+
cache.setList(list ?? []);
|
|
265
|
+
list = cache.getList();
|
|
266
|
+
}
|
|
267
|
+
return textResult(list.filter((i) => matchesKeyword(i, keyword)));
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
return errResult(e);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
server.registerTool("create_api", {
|
|
274
|
+
description: "【会修改项目数据】在友接口项目中新建接口文档。请同时传 params/body/responses,推完后可用 get_api_detail 回读。需要 PAT 含 apis:write。\n\n" +
|
|
275
|
+
UPSERT_EXAMPLE,
|
|
276
|
+
inputSchema: z.object(writeFields),
|
|
277
|
+
annotations: {
|
|
278
|
+
title: "创建友接口",
|
|
279
|
+
destructiveHint: true,
|
|
280
|
+
readOnlyHint: false,
|
|
281
|
+
},
|
|
282
|
+
}, async (args) => {
|
|
283
|
+
try {
|
|
284
|
+
const result = await client.createApi(toWriteBody(args));
|
|
285
|
+
cache.invalidateAfterWrite(result?.id);
|
|
286
|
+
return textResult(result);
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
return errResult(e);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
server.registerTool("update_api", {
|
|
293
|
+
description: "【会修改项目数据】按 id 更新已有友接口文档(merge:未传字段保留原值)。更新 params/body 时请传完整列表/对象。需要 apis:write。\n\n" +
|
|
294
|
+
UPSERT_EXAMPLE,
|
|
295
|
+
inputSchema: z.object({
|
|
296
|
+
apiId: z.union([z.string(), z.number()]).describe("要更新的接口 ID"),
|
|
297
|
+
name: z.string().optional(),
|
|
298
|
+
method: z.string().optional(),
|
|
299
|
+
url: z.string().optional(),
|
|
300
|
+
description: z.string().optional(),
|
|
301
|
+
sourcePath: z
|
|
302
|
+
.string()
|
|
303
|
+
.optional()
|
|
304
|
+
.describe("接口来源路径;传了会更新文档「接口说明」的「来源」展示"),
|
|
305
|
+
moduleName: z
|
|
306
|
+
.string()
|
|
307
|
+
.optional()
|
|
308
|
+
.describe("仅用户明确指定模块时传入:有则归入、无则新建;省略归入「默认模块」"),
|
|
309
|
+
folderPath: z
|
|
310
|
+
.union([z.array(z.string()), z.string()])
|
|
311
|
+
.optional()
|
|
312
|
+
.describe('仅用户指定文件夹时传入;省略则按 url 自动建外层文件夹,不摊平'),
|
|
313
|
+
menuId: z.number().int().positive().optional(),
|
|
314
|
+
moduleId: z.number().int().positive().optional(),
|
|
315
|
+
params: z.array(paramSchema).optional(),
|
|
316
|
+
body: bodySchema.optional(),
|
|
317
|
+
responses: z.array(responseSchema).optional(),
|
|
318
|
+
status: z.string().optional(),
|
|
319
|
+
tags: z.array(z.string()).optional(),
|
|
320
|
+
}),
|
|
321
|
+
annotations: {
|
|
322
|
+
title: "更新友接口",
|
|
323
|
+
destructiveHint: true,
|
|
324
|
+
readOnlyHint: false,
|
|
325
|
+
},
|
|
326
|
+
}, async ({ apiId, ...rest }) => {
|
|
327
|
+
try {
|
|
328
|
+
const id = typeof apiId === "number" ? apiId : Number(apiId);
|
|
329
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
330
|
+
return textResult("apiId 无效", true);
|
|
331
|
+
}
|
|
332
|
+
const result = await client.updateApi(id, toUpdatePatch(rest));
|
|
333
|
+
cache.invalidateAfterWrite(id);
|
|
334
|
+
return textResult(result);
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
return errResult(e);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
server.registerTool("upsert_api", {
|
|
341
|
+
description: "【会修改项目数据】按 method+url 推送接口到友接口:存在则 merge 更新,不存在则创建。推荐作为从代码/OpenAPI 同步的主入口。请一并推送 params/body;省略 folderPath 时服务端按 url 自动建外层文件夹(对标 Apifox,不摊平),用户指定目录时再传 folderPath。需要 apis:write。\n\n" +
|
|
342
|
+
UPSERT_EXAMPLE,
|
|
343
|
+
inputSchema: z.object(writeFields),
|
|
344
|
+
annotations: {
|
|
345
|
+
title: "Upsert 友接口",
|
|
346
|
+
destructiveHint: true,
|
|
347
|
+
readOnlyHint: false,
|
|
348
|
+
},
|
|
349
|
+
}, async (args) => {
|
|
350
|
+
try {
|
|
351
|
+
const result = await client.upsertApi(toWriteBody(args));
|
|
352
|
+
cache.invalidateAfterWrite(result?.id);
|
|
353
|
+
return textResult(result);
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
return errResult(e);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
server.registerTool("refresh_cache", {
|
|
360
|
+
description: "清空本进程内的接口列表/详情缓存,下次 list/get 会重新请求后端。",
|
|
361
|
+
inputSchema: z.object({}),
|
|
362
|
+
}, async () => {
|
|
363
|
+
cache.clear();
|
|
364
|
+
return textResult({ ok: true, message: "缓存已清空" });
|
|
365
|
+
});
|
|
366
|
+
return server;
|
|
367
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type YoujkConfig = {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
accessToken: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
};
|
|
6
|
+
export type ApiSummary = {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
method: string;
|
|
10
|
+
url: string;
|
|
11
|
+
menuId?: number;
|
|
12
|
+
moduleId?: number;
|
|
13
|
+
};
|
|
14
|
+
export type YoujkEnvelope<T> = {
|
|
15
|
+
code: number;
|
|
16
|
+
message?: string;
|
|
17
|
+
result?: T;
|
|
18
|
+
};
|
|
19
|
+
export type ApiWriteResult = {
|
|
20
|
+
action: "created" | "updated";
|
|
21
|
+
id: number;
|
|
22
|
+
name?: string;
|
|
23
|
+
method?: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
};
|
|
26
|
+
export type ApiWriteBody = {
|
|
27
|
+
name: string;
|
|
28
|
+
method: string;
|
|
29
|
+
url: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
/** 接口来源路径,文档预览「接口说明」区展示 */
|
|
32
|
+
sourcePath?: string;
|
|
33
|
+
/** 用户指定模块时:有则归入、无则新建;省略则归入「默认模块」 */
|
|
34
|
+
moduleName?: string;
|
|
35
|
+
/** 用户指定目录时使用;省略则按 url 自动建外层文件夹(不摊平) */
|
|
36
|
+
folderPath?: string | string[];
|
|
37
|
+
menuId?: number;
|
|
38
|
+
moduleId?: number;
|
|
39
|
+
params?: unknown;
|
|
40
|
+
body?: unknown;
|
|
41
|
+
responses?: unknown;
|
|
42
|
+
status?: string;
|
|
43
|
+
tags?: string[];
|
|
44
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "youjiekou-mcp-server",
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "YouJK (友接口) MCP Server - read and push API definitions via MCP tools",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"youjiekou-mcp-server": "bin/youjk-mcp-server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"dev": "tsc && node dist/index.js",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"youjk",
|
|
26
|
+
"api"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
31
|
+
"zod": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.10.0",
|
|
35
|
+
"typescript": "^5.7.0"
|
|
36
|
+
}
|
|
37
|
+
}
|