@uwa4d/openapi-mcp 0.2.0-beta.9 → 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/cli.js CHANGED
@@ -6,6 +6,7 @@ import { compositeFilterKeys, selectCompositeTools, COMPOSITE_TOOLS } from './co
6
6
  import { filterKeys, selectOperations } from './presets.js';
7
7
  import { DEFAULT_MAX_CHARS, DEFAULT_MAX_ROWS, toolName } from './tools.js';
8
8
  import { PACKAGE_VERSION, startStdio } from './server.js';
9
+ import { checkForUpdate, describeReleaseChannel, formatUpdateNotice } from './version-check.js';
9
10
  const DEFAULT_BASE_URL = 'https://secure-api.uwa4d.com';
10
11
  const SANDBOX_BASE_URL = 'https://sandbox-api.uwa4d.com';
11
12
  function splitList(value) {
@@ -133,6 +134,16 @@ program
133
134
  console.log('\n凭证有效,接口连通。');
134
135
  if (typeof total === 'number')
135
136
  console.log(`最近 7 天有 ${total} 份报告。`);
137
+ console.log(`\nMCP 包版本:${PACKAGE_VERSION}(${describeReleaseChannel(PACKAGE_VERSION).releaseLabel})`);
138
+ if (process.env['UWA_MCP_SKIP_UPDATE_CHECK'] !== '1') {
139
+ const update = await checkForUpdate(PACKAGE_VERSION);
140
+ if (update) {
141
+ console.log(formatUpdateNotice(update));
142
+ }
143
+ else {
144
+ console.log('已是最新同 release 线版本(或暂时无法连接 npm registry)。');
145
+ }
146
+ }
136
147
  console.log('\n可以按 README 配置 MCP 客户端了。');
137
148
  }
138
149
  catch (err) {
@@ -2,8 +2,10 @@ import { toolName } from '../tools.js';
2
2
  import { topResourcesTool } from './top-resources.js';
3
3
  import { topFunctionsTool } from './top-functions.js';
4
4
  import { reportDiagnosisTool } from './report-diagnosis.js';
5
+ import { mcpVersionTool } from './mcp-version.js';
5
6
  /** 全部手写复合工具(不进 uwa-openapi.json)。 */
6
7
  export const COMPOSITE_TOOLS = [
8
+ mcpVersionTool,
7
9
  topResourcesTool,
8
10
  topFunctionsTool,
9
11
  reportDiagnosisTool,
@@ -0,0 +1,2 @@
1
+ import type { CompositeTool } from './types.js';
2
+ export declare const mcpVersionTool: CompositeTool;
@@ -0,0 +1,48 @@
1
+ import { PACKAGE_VERSION } from '../package-version.js';
2
+ import { buildMcpVersionInfo, describeReleaseChannel } from '../version-check.js';
3
+ import { errorResult, jsonToolResult } from './helpers.js';
4
+ const release = describeReleaseChannel(PACKAGE_VERSION);
5
+ const STABLE_DESCRIPTION = [
6
+ '查询当前 UWA OpenAPI MCP 正式版版本,并与 npm 最新稳定版对比(仅正式版↔正式版,不与内部测试版交叉)。',
7
+ '适用:Unity / UE|复合工具|无需 dataKey',
8
+ '触发:本对话第一次使用任意 UWA MCP 工具前应先调一次;用户问版本/升级时也可调。同一对话勿重复。',
9
+ '若 updateAvailable=true / suggestTellUser=true,请用 userNotice 主动转告客户重启 MCP 升级;已是最新勿打扰。',
10
+ '不要臆测版本号。',
11
+ ].join('\n');
12
+ const INTERNAL_DESCRIPTION = [
13
+ '查询当前 UWA OpenAPI MCP 内部测试版版本,并与 npm @beta 最新测试版对比(仅测试↔测试,不与正式版交叉)。',
14
+ '适用:Unity / UE|复合工具|无需 dataKey|仅 UWA 内部 sandbox 联调',
15
+ '触发:本对话第一次使用任意 UWA MCP 工具前应先调一次;用户问版本/升级时也可调。同一对话勿重复。',
16
+ '若 updateAvailable=true / suggestTellUser=true,请用 userNotice 主动转告测试人员重启 MCP 升级;已是最新勿打扰。',
17
+ '不要臆测版本号。',
18
+ ].join('\n');
19
+ export const mcpVersionTool = {
20
+ id: 'uwa_mcp_version',
21
+ title: release.releaseLine === 'stable' ? 'MCP 正式版与升级提示' : 'MCP 内部测试版与升级提示',
22
+ engines: ['unity', 'unreal'],
23
+ filterKeys: ['uwa_mcp_version', 'composite', 'preset.default', 'common'],
24
+ description: release.releaseLine === 'stable' ? STABLE_DESCRIPTION : INTERNAL_DESCRIPTION,
25
+ inputSchema: {},
26
+ async handler(_args, ctx) {
27
+ try {
28
+ if (process.env['UWA_MCP_SKIP_UPDATE_CHECK'] === '1') {
29
+ return jsonToolResult({
30
+ package: '@uwa4d/openapi-mcp',
31
+ currentVersion: PACKAGE_VERSION,
32
+ releaseLine: release.releaseLine,
33
+ releaseLabel: release.releaseLabel,
34
+ updateCheckSkipped: true,
35
+ userNotice: `当前 MCP ${release.releaseLabel} ${PACKAGE_VERSION}(已设置 UWA_MCP_SKIP_UPDATE_CHECK,未核对 npm 最新版)。`,
36
+ }, ctx.maxChars);
37
+ }
38
+ const info = await buildMcpVersionInfo(PACKAGE_VERSION);
39
+ return jsonToolResult({
40
+ ...info,
41
+ suggestTellUser: info.updateAvailable,
42
+ }, ctx.maxChars);
43
+ }
44
+ catch (err) {
45
+ return errorResult(err);
46
+ }
47
+ },
48
+ };
@@ -1,3 +1,4 @@
1
+ import { withRateLimitAnnotation } from '../error-hints.js';
1
2
  import { z } from './types.js';
2
3
  import { errorResult, isObj, jsonToolResult, requireReportKey, topNOf, } from './helpers.js';
3
4
  import { fetchOverviewStatistic, fetchReportIdentity, preferAtResourceTable, } from './route-overview.js';
@@ -193,26 +194,24 @@ export async function runTopResources(client, args) {
193
194
  apisUsed.push(...fetched.apisUsed);
194
195
  if (fetched.items.length === 0) {
195
196
  const fallbackItems = typeLevelFromOverview(view);
196
- return packResult(fallbackItems, {
197
+ return packResult(fallbackItems, withRateLimitAnnotation({
197
198
  _sourceKind: fetched.sourceKind,
198
199
  _apisUsed: apisUsed,
199
- ...(fetched.errors.length ? { _partialErrors: fetched.errors } : {}),
200
200
  _note: fallbackItems.length > 0
201
201
  ? '逐资源列表为空,已回退为 Overview 类型级峰值。'
202
202
  : '逐资源列表与类型级峰值均为空(报告可能未采集资源内存)。',
203
203
  _limitations: ['per_asset_empty'],
204
- });
204
+ }, fetched.errors.length ? fetched.errors : undefined));
205
205
  }
206
206
  const note = groupBy === 'assetType'
207
207
  ? `已按类型各取 Top ${topN}(${fetched.sourceKind},共 ${assetTypes.length} 种)。`
208
208
  : `已按内存峰值合并 ${assetTypes.length} 种资源类型(${fetched.sourceKind}),返回全局 Top ${topN}。`;
209
- return packResult(fetched.items, {
209
+ return packResult(fetched.items, withRateLimitAnnotation({
210
210
  totalCandidates: fetched.items.length,
211
211
  _sourceKind: fetched.sourceKind,
212
212
  _apisUsed: apisUsed,
213
- ...(fetched.errors.length ? { _partialErrors: fetched.errors } : {}),
214
213
  _note: note,
215
- });
214
+ }, fetched.errors.length ? fetched.errors : undefined));
216
215
  }
217
216
  function normalizeAssetTypes(raw) {
218
217
  if (Array.isArray(raw) && raw.length)
@@ -236,6 +235,7 @@ export const topResourcesTool = {
236
235
  'Unity / UE 均优先走 AT 资源总览预签名(at/resource/overall/table/presign),按 assetType 并行下载后合并排序。',
237
236
  'groupBy=merged(默认):跨类型合并后取全局 Top N;groupBy=assetType:每种类型各取 Top N,返回 byType。',
238
237
  'Unity 旧报告(解析日 < 2026-06-25)回退 memory/manage;拉不到逐资源时再回退 Overview 类型级峰值。',
238
+ '内部按 assetType 并行请求,易触发 OpenAPI 限流 31004(UWA 服务端保护,预期行为)。遇限流见 _rateLimitHint:可缩小 assetTypes、稍等重试;部分类型限流时已有 Top 仍可用。',
239
239
  '不确定报告版本时直接用本工具;需要原始全量列表时再调对应原子工具。',
240
240
  ].join('\n'),
241
241
  inputSchema: {
@@ -9,5 +9,15 @@ export declare const FILE_META_REASONS: {
9
9
  readonly AT_META_TYPE_INVALID: "AT_META_TYPE_INVALID";
10
10
  readonly AT_META_PATH_MISSING: "AT_META_PATH_MISSING";
11
11
  };
12
+ /** OpenAPI 接口限流(UWA 服务端保护,预期行为)。 */
13
+ export declare const RATE_LIMIT_ERROR_CODE = 31004;
14
+ /** 复合工具 _partialErrors 含限流时附加的说明(与 resolveErrorHint(31004) 语义一致)。 */
15
+ export declare const RATE_LIMIT_HINT: string;
16
+ /** 错误文案是否含 31004 / API_RATE_LIMIT。 */
17
+ export declare function isRateLimitMessage(text: string): boolean;
18
+ /** _partialErrors 中是否有限流项。 */
19
+ export declare function hasRateLimitInPartialErrors(errors: string[]): boolean;
20
+ /** 附带 _partialErrors;若含限流则加 _rateLimitHint。 */
21
+ export declare function withRateLimitAnnotation(extra: Record<string, unknown>, partialErrors?: string[]): Record<string, unknown>;
12
22
  /** 组装给模型看的排查建议;优先 rawMessage 原因码 + OpenAPI message。 */
13
23
  export declare function resolveErrorHint(code: number, rawMessage: string, apiMessage: string): string | undefined;
@@ -9,6 +9,11 @@ export const FILE_META_REASONS = {
9
9
  AT_META_TYPE_INVALID: 'AT_META_TYPE_INVALID',
10
10
  AT_META_PATH_MISSING: 'AT_META_PATH_MISSING',
11
11
  };
12
+ /** OpenAPI 接口限流(UWA 服务端保护,预期行为)。 */
13
+ export const RATE_LIMIT_ERROR_CODE = 31004;
14
+ /** 复合工具 _partialErrors 含限流时附加的说明(与 resolveErrorHint(31004) 语义一致)。 */
15
+ export const RATE_LIMIT_HINT = 'OpenAPI 限流(31004)是 UWA 服务端保护,不是报告无数据或接口故障。可稍等重试、减少并发或缩小 assetTypes;' +
16
+ '若 _partialErrors 仅部分类型限流,已有 Top 仍可用,需说明可能不完整。';
12
17
  const ERROR_HINTS_BY_CODE = {
13
18
  20001: '业务参数不合法,核对参数名、取值范围和必填项(注意批量接口的参数名多为复数,如 dataKeys)',
14
19
  23508: '数据服务错误,常见原因是报告不适用该接口版本(如新报告调用了 1.0 接口,或旧报告调用了 2.0 接口),改用对应版本重试',
@@ -18,6 +23,8 @@ const ERROR_HINTS_BY_CODE = {
18
23
  24056: '签名错误,检查 appSecret 是否正确',
19
24
  24057: '时间戳过期,本机时间与服务端偏差不能超过 20 分钟',
20
25
  30001: '服务端错误,常见原因是用错了引擎对应的接口(如对 UE 报告调用了 Unity 专用接口);勿把瞬时失败说成「一定没有数据」',
26
+ 31004: 'OpenAPI 接口限流(UWA 服务端保护,预期行为)。稍等重试,或减少并行请求/缩小查询范围(如 top_resources 的 assetTypes);' +
27
+ '勿当成报告无数据或接口永久故障。',
21
28
  };
22
29
  /** OpenAPI 已按引擎写好的稳定文案(优先沿用,再补 AI 纪律)。 */
23
30
  function hintForOpenApiRuntimeLogMessage(apiMessage) {
@@ -45,6 +52,24 @@ function hintForFileMetaReason(rawMessage, apiMessage) {
45
52
  }
46
53
  return null;
47
54
  }
55
+ /** 错误文案是否含 31004 / API_RATE_LIMIT。 */
56
+ export function isRateLimitMessage(text) {
57
+ return text.includes(`[${RATE_LIMIT_ERROR_CODE}]`) || text.includes('API_RATE_LIMIT') || text.includes('接口限流');
58
+ }
59
+ /** _partialErrors 中是否有限流项。 */
60
+ export function hasRateLimitInPartialErrors(errors) {
61
+ return errors.some(isRateLimitMessage);
62
+ }
63
+ /** 附带 _partialErrors;若含限流则加 _rateLimitHint。 */
64
+ export function withRateLimitAnnotation(extra, partialErrors) {
65
+ if (!partialErrors?.length)
66
+ return extra;
67
+ const out = { ...extra, _partialErrors: partialErrors };
68
+ if (hasRateLimitInPartialErrors(partialErrors)) {
69
+ out._rateLimitHint = RATE_LIMIT_HINT;
70
+ }
71
+ return out;
72
+ }
48
73
  /** 组装给模型看的排查建议;优先 rawMessage 原因码 + OpenAPI message。 */
49
74
  export function resolveErrorHint(code, rawMessage, apiMessage) {
50
75
  if (code === 80108) {
@@ -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
+ }