@uwa4d/openapi-mcp 0.2.0 → 0.2.1-beta.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/eureka.d.ts +15 -0
- package/dist/eureka.js +142 -0
- package/dist/http.d.ts +21 -0
- package/dist/http.js +203 -0
- package/dist/indicator-dashboard-keys.json +366 -336
- package/dist/presets.js +1 -0
- package/dist/version-guide.js +10 -2
- package/package.json +1 -1
- package/spec/uwa-openapi.json +158 -78
package/dist/eureka.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** 与网关 `lb://uwa-openapi-mcp`、Eureka vipAddress 对齐。 */
|
|
2
|
+
export declare const EUREKA_APP_NAME = "uwa-openapi-mcp";
|
|
3
|
+
export interface EurekaOptions {
|
|
4
|
+
/** 如 http://user:pass@host:8761/eureka/ */
|
|
5
|
+
zone: string;
|
|
6
|
+
ip: string;
|
|
7
|
+
port: number;
|
|
8
|
+
hostname?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function resolveAdvertiseIp(explicit?: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* 注册到 Eureka,并维持心跳。返回的 stop 会下线实例。
|
|
13
|
+
* 注册失败会后台重试,不打断 HTTP 服务。
|
|
14
|
+
*/
|
|
15
|
+
export declare function startEurekaRegistration(opts: EurekaOptions): () => Promise<void>;
|
package/dist/eureka.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { networkInterfaces } from 'node:os';
|
|
2
|
+
/** 与网关 `lb://uwa-openapi-mcp`、Eureka vipAddress 对齐。 */
|
|
3
|
+
export const EUREKA_APP_NAME = 'uwa-openapi-mcp';
|
|
4
|
+
function normalizeZone(zone) {
|
|
5
|
+
return zone.endsWith('/') ? zone : `${zone}/`;
|
|
6
|
+
}
|
|
7
|
+
function firstNonInternalIpv4() {
|
|
8
|
+
for (const addrs of Object.values(networkInterfaces())) {
|
|
9
|
+
for (const addr of addrs ?? []) {
|
|
10
|
+
const family = String(addr.family);
|
|
11
|
+
if ((family === 'IPv4' || family === '4') && !addr.internal)
|
|
12
|
+
return addr.address;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
export function resolveAdvertiseIp(explicit) {
|
|
18
|
+
const ip = explicit?.trim() || process.env['EUREKA_INSTANCE_IP']?.trim() || firstNonInternalIpv4();
|
|
19
|
+
if (!ip) {
|
|
20
|
+
throw new Error('无法探测注册 IP。请设置环境变量 EUREKA_INSTANCE_IP(网关要能访问到的地址,不要用 127.0.0.1)。');
|
|
21
|
+
}
|
|
22
|
+
if (ip === '127.0.0.1' || ip === '::1') {
|
|
23
|
+
throw new Error(`Eureka 不能注册 ${ip},网关在别的进程里连不上。请设置 EUREKA_INSTANCE_IP 为测试机内网 IP。`);
|
|
24
|
+
}
|
|
25
|
+
return ip;
|
|
26
|
+
}
|
|
27
|
+
function instancePayload(opts, instanceId) {
|
|
28
|
+
const host = opts.hostname ?? opts.ip;
|
|
29
|
+
const base = `http://${opts.ip}:${opts.port}`;
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
return {
|
|
32
|
+
instance: {
|
|
33
|
+
instanceId,
|
|
34
|
+
hostName: host,
|
|
35
|
+
app: EUREKA_APP_NAME.toUpperCase(),
|
|
36
|
+
ipAddr: opts.ip,
|
|
37
|
+
status: 'UP',
|
|
38
|
+
port: { $: opts.port, '@enabled': true },
|
|
39
|
+
securePort: { $: 443, '@enabled': false },
|
|
40
|
+
countryId: 1,
|
|
41
|
+
dataCenterInfo: {
|
|
42
|
+
'@class': 'com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo',
|
|
43
|
+
name: 'MyOwn',
|
|
44
|
+
},
|
|
45
|
+
leaseInfo: {
|
|
46
|
+
renewalIntervalInSecs: 30,
|
|
47
|
+
durationInSecs: 90,
|
|
48
|
+
},
|
|
49
|
+
homePageUrl: `${base}/`,
|
|
50
|
+
statusPageUrl: `${base}/health`,
|
|
51
|
+
healthCheckUrl: `${base}/health`,
|
|
52
|
+
vipAddress: EUREKA_APP_NAME,
|
|
53
|
+
secureVipAddress: EUREKA_APP_NAME,
|
|
54
|
+
lastUpdatedTimestamp: now,
|
|
55
|
+
lastDirtyTimestamp: now,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function eurekaFetch(url, init) {
|
|
60
|
+
return fetch(url, {
|
|
61
|
+
...init,
|
|
62
|
+
headers: {
|
|
63
|
+
Accept: 'application/json',
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
...(init.headers ?? {}),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 注册到 Eureka,并维持心跳。返回的 stop 会下线实例。
|
|
71
|
+
* 注册失败会后台重试,不打断 HTTP 服务。
|
|
72
|
+
*/
|
|
73
|
+
export function startEurekaRegistration(opts) {
|
|
74
|
+
const zone = normalizeZone(opts.zone);
|
|
75
|
+
const app = EUREKA_APP_NAME.toUpperCase();
|
|
76
|
+
const instanceId = `${opts.ip}:${EUREKA_APP_NAME}:${opts.port}`;
|
|
77
|
+
const registerUrl = `${zone}apps/${app}`;
|
|
78
|
+
const instanceUrl = `${zone}apps/${app}/${encodeURIComponent(instanceId)}`;
|
|
79
|
+
const body = JSON.stringify(instancePayload(opts, instanceId));
|
|
80
|
+
let stopped = false;
|
|
81
|
+
let heartbeat;
|
|
82
|
+
let retry;
|
|
83
|
+
const log = (msg, err) => {
|
|
84
|
+
const extra = err instanceof Error ? ` ${err.message}` : err ? ` ${String(err)}` : '';
|
|
85
|
+
console.error(`[uwa-openapi-mcp] Eureka ${msg}${extra}`);
|
|
86
|
+
};
|
|
87
|
+
const sendHeartbeat = async () => {
|
|
88
|
+
if (stopped)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
const res = await eurekaFetch(instanceUrl, { method: 'PUT' });
|
|
92
|
+
if (res.status === 404) {
|
|
93
|
+
log('心跳 404,重新注册');
|
|
94
|
+
await register();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok)
|
|
98
|
+
log(`心跳失败 HTTP ${res.status}`);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
log('心跳异常', err);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const register = async () => {
|
|
105
|
+
if (stopped)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
const res = await eurekaFetch(registerUrl, { method: 'POST', body });
|
|
109
|
+
if (res.ok || res.status === 204) {
|
|
110
|
+
log(`已注册 ${instanceId} → ${zone}`);
|
|
111
|
+
if (!heartbeat)
|
|
112
|
+
heartbeat = setInterval(() => void sendHeartbeat(), 30_000);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const text = await res.text().catch(() => '');
|
|
116
|
+
log(`注册失败 HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
log('注册异常', err);
|
|
120
|
+
}
|
|
121
|
+
if (!stopped)
|
|
122
|
+
retry = setTimeout(() => void register(), 10_000);
|
|
123
|
+
};
|
|
124
|
+
void register();
|
|
125
|
+
return async () => {
|
|
126
|
+
stopped = true;
|
|
127
|
+
if (retry)
|
|
128
|
+
clearTimeout(retry);
|
|
129
|
+
if (heartbeat)
|
|
130
|
+
clearInterval(heartbeat);
|
|
131
|
+
try {
|
|
132
|
+
const res = await eurekaFetch(instanceUrl, { method: 'DELETE' });
|
|
133
|
+
if (res.ok || res.status === 404)
|
|
134
|
+
log(`已下线 ${instanceId}`);
|
|
135
|
+
else
|
|
136
|
+
log(`下线失败 HTTP ${res.status}`);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
log('下线异常', err);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ServerOptions } from './server.js';
|
|
2
|
+
export interface HttpListenOptions extends Omit<ServerOptions, 'appId' | 'appSecret'> {
|
|
3
|
+
port: number;
|
|
4
|
+
bind: string;
|
|
5
|
+
/** MCP 路径,默认 /mcp */
|
|
6
|
+
path: string;
|
|
7
|
+
/** 请求未带凭证时的兜底(CLI / 环境变量) */
|
|
8
|
+
fallbackCredentials?: {
|
|
9
|
+
appId: string;
|
|
10
|
+
appSecret: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* true:只回 JSON,不走 SSE。
|
|
14
|
+
* 默认 false,符合 Streamable HTTP / 飞书 HTTPStreaming。
|
|
15
|
+
*/
|
|
16
|
+
jsonResponse: boolean;
|
|
17
|
+
/** 设置后向 Eureka 注册,供网关 lb://uwa-openapi-mcp 发现 */
|
|
18
|
+
eurekaZone?: string;
|
|
19
|
+
eurekaIp?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function startHttp(opts: HttpListenOptions): Promise<void>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import { startEurekaRegistration, resolveAdvertiseIp } from './eureka.js';
|
|
4
|
+
import { SERVER_INSTRUCTIONS } from './server-instructions.js';
|
|
5
|
+
import { PACKAGE_VERSION, createServer } from './server.js';
|
|
6
|
+
import { buildMcpVersionInfo, buildUpdateInstructionAppendix, scheduleUpdateNotice } from './version-check.js';
|
|
7
|
+
function header(req, name) {
|
|
8
|
+
const raw = req.headers[name.toLowerCase()];
|
|
9
|
+
if (Array.isArray(raw))
|
|
10
|
+
return raw[0];
|
|
11
|
+
return raw;
|
|
12
|
+
}
|
|
13
|
+
function firstQuery(url, keys) {
|
|
14
|
+
for (const key of keys) {
|
|
15
|
+
const value = url.searchParams.get(key)?.trim();
|
|
16
|
+
if (value)
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
function credentialsFromBasic(req) {
|
|
22
|
+
const auth = header(req, 'authorization');
|
|
23
|
+
if (!auth || !auth.toLowerCase().startsWith('basic '))
|
|
24
|
+
return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const decoded = Buffer.from(auth.slice(6).trim(), 'base64').toString('utf8');
|
|
27
|
+
const colon = decoded.indexOf(':');
|
|
28
|
+
if (colon <= 0)
|
|
29
|
+
return undefined;
|
|
30
|
+
const appId = decoded.slice(0, colon).trim();
|
|
31
|
+
const appSecret = decoded.slice(colon + 1).trim();
|
|
32
|
+
if (!appId || !appSecret)
|
|
33
|
+
return undefined;
|
|
34
|
+
return { appId, appSecret };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function resolveRequestCredentials(req, url, fallback) {
|
|
41
|
+
const appId = firstQuery(url, ['appId', 'app_id']) ??
|
|
42
|
+
header(req, 'x-uwa-open-api-app-id') ??
|
|
43
|
+
fallback?.appId;
|
|
44
|
+
const appSecret = firstQuery(url, ['appSecret', 'app_secret']) ??
|
|
45
|
+
header(req, 'x-uwa-mcp-app-secret') ??
|
|
46
|
+
header(req, 'x-uwa-open-api-app-secret') ??
|
|
47
|
+
fallback?.appSecret;
|
|
48
|
+
if (appId && appSecret)
|
|
49
|
+
return { appId, appSecret };
|
|
50
|
+
return credentialsFromBasic(req);
|
|
51
|
+
}
|
|
52
|
+
async function readJsonBody(req) {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for await (const chunk of req) {
|
|
55
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
56
|
+
}
|
|
57
|
+
if (chunks.length === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
60
|
+
if (!raw)
|
|
61
|
+
return undefined;
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
function sendJson(res, status, body) {
|
|
65
|
+
if (res.headersSent)
|
|
66
|
+
return;
|
|
67
|
+
const payload = JSON.stringify(body);
|
|
68
|
+
res.writeHead(status, {
|
|
69
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
70
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
71
|
+
});
|
|
72
|
+
res.end(payload);
|
|
73
|
+
}
|
|
74
|
+
function applyCors(res) {
|
|
75
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
76
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
77
|
+
res.setHeader('Access-Control-Allow-Headers', [
|
|
78
|
+
'Content-Type',
|
|
79
|
+
'Accept',
|
|
80
|
+
'Authorization',
|
|
81
|
+
'MCP-Protocol-Version',
|
|
82
|
+
'mcp-protocol-version',
|
|
83
|
+
'MCP-Session-Id',
|
|
84
|
+
'mcp-session-id',
|
|
85
|
+
'x-uwa-open-api-app-id',
|
|
86
|
+
'x-uwa-mcp-app-secret',
|
|
87
|
+
'x-uwa-open-api-app-secret',
|
|
88
|
+
].join(', '));
|
|
89
|
+
}
|
|
90
|
+
function isMcpPath(pathname, mcpPath) {
|
|
91
|
+
return pathname === mcpPath || pathname === `${mcpPath}/`;
|
|
92
|
+
}
|
|
93
|
+
async function handleMcp(req, res, url, opts, instructions) {
|
|
94
|
+
const creds = resolveRequestCredentials(req, url, opts.fallbackCredentials);
|
|
95
|
+
if (!creds) {
|
|
96
|
+
sendJson(res, 401, {
|
|
97
|
+
error: '缺少凭证',
|
|
98
|
+
hint: '在 URL 上带 appId、appSecret,或设置 Header x-uwa-open-api-app-id / x-uwa-mcp-app-secret',
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
let parsedBody;
|
|
103
|
+
if (req.method === 'POST') {
|
|
104
|
+
try {
|
|
105
|
+
parsedBody = await readJsonBody(req);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
sendJson(res, 400, { error: '请求体不是合法 JSON' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const { server } = createServer({
|
|
113
|
+
...opts,
|
|
114
|
+
appId: creds.appId,
|
|
115
|
+
appSecret: creds.appSecret,
|
|
116
|
+
instructions,
|
|
117
|
+
});
|
|
118
|
+
const transport = new StreamableHTTPServerTransport({
|
|
119
|
+
sessionIdGenerator: undefined,
|
|
120
|
+
enableJsonResponse: opts.jsonResponse,
|
|
121
|
+
});
|
|
122
|
+
const shutdown = () => {
|
|
123
|
+
void transport.close();
|
|
124
|
+
void server.close();
|
|
125
|
+
};
|
|
126
|
+
res.on('close', shutdown);
|
|
127
|
+
await server.connect(transport);
|
|
128
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
129
|
+
}
|
|
130
|
+
export async function startHttp(opts) {
|
|
131
|
+
let instructions = SERVER_INSTRUCTIONS;
|
|
132
|
+
if (process.env['UWA_MCP_SKIP_UPDATE_CHECK'] !== '1') {
|
|
133
|
+
try {
|
|
134
|
+
const info = await buildMcpVersionInfo(PACKAGE_VERSION);
|
|
135
|
+
const appendix = buildUpdateInstructionAppendix(info);
|
|
136
|
+
if (appendix)
|
|
137
|
+
instructions = `${SERVER_INSTRUCTIONS}\n\n${appendix}`;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* 版本检查失败不阻塞启动 */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const preview = createServer({
|
|
144
|
+
...opts,
|
|
145
|
+
appId: opts.fallbackCredentials?.appId ?? 'preview',
|
|
146
|
+
appSecret: opts.fallbackCredentials?.appSecret ?? 'preview',
|
|
147
|
+
instructions,
|
|
148
|
+
});
|
|
149
|
+
await preview.server.close();
|
|
150
|
+
const httpServer = createHttpServer((req, res) => {
|
|
151
|
+
void (async () => {
|
|
152
|
+
applyCors(res);
|
|
153
|
+
const host = req.headers.host ?? 'localhost';
|
|
154
|
+
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
155
|
+
if (req.method === 'OPTIONS' && isMcpPath(url.pathname, opts.path)) {
|
|
156
|
+
res.writeHead(204);
|
|
157
|
+
res.end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (req.method === 'GET' && (url.pathname === '/health' || url.pathname === '/health/')) {
|
|
161
|
+
sendJson(res, 200, { status: 'ok', name: 'uwa-openapi-mcp', version: PACKAGE_VERSION });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!isMcpPath(url.pathname, opts.path)) {
|
|
165
|
+
sendJson(res, 404, { error: `仅提供 ${opts.path}` });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
await handleMcp(req, res, url, opts, instructions);
|
|
169
|
+
})().catch((err) => {
|
|
170
|
+
console.error('[uwa-openapi-mcp] HTTP 处理失败', err);
|
|
171
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
await new Promise((resolve, reject) => {
|
|
175
|
+
httpServer.once('error', reject);
|
|
176
|
+
httpServer.listen(opts.port, opts.bind, () => resolve());
|
|
177
|
+
});
|
|
178
|
+
console.error(`[uwa-openapi-mcp] v${PACKAGE_VERSION} HTTP 已启动 ${opts.bind}:${opts.port}${opts.path},` +
|
|
179
|
+
`已加载 ${preview.toolCount}/${preview.total} 个工具(原子 ${preview.atomicCount} + 复合 ${preview.compositeCount}),` +
|
|
180
|
+
`接口地址 ${opts.baseUrl}` +
|
|
181
|
+
(opts.jsonResponse ? ',JSON 响应' : ',Streamable HTTP'));
|
|
182
|
+
scheduleUpdateNotice(PACKAGE_VERSION);
|
|
183
|
+
let stopEureka;
|
|
184
|
+
const zone = opts.eurekaZone ?? process.env['EUREKA_DEFAULT_ZONE']?.trim();
|
|
185
|
+
if (zone) {
|
|
186
|
+
const ip = resolveAdvertiseIp(opts.eurekaIp);
|
|
187
|
+
stopEureka = startEurekaRegistration({ zone, ip, port: opts.port });
|
|
188
|
+
}
|
|
189
|
+
let shuttingDown = false;
|
|
190
|
+
const shutdown = (signal) => {
|
|
191
|
+
if (shuttingDown)
|
|
192
|
+
return;
|
|
193
|
+
shuttingDown = true;
|
|
194
|
+
console.error(`[uwa-openapi-mcp] 收到 ${signal},准备退出`);
|
|
195
|
+
void (async () => {
|
|
196
|
+
await stopEureka?.();
|
|
197
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
198
|
+
process.exit(0);
|
|
199
|
+
})();
|
|
200
|
+
};
|
|
201
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
202
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
203
|
+
}
|